How to notify parent component of property change when using react hooks? - reactjs

Lets say I have a parent component and child component. The parent component is composed of several child components. The parent component holds and manages a very complex and deep data object. Each child component provides the UI to manage various child objects and properties of the main data object. Whenever the child component changes a property value in the data object hierarchy, that change needs to bubble up to the main data object.
Here is how I might do it in a child component class by passing in a callback object...
<div>
<button onClick={e => this.setState({propA: e.target.value}, () => props.onChangePropA(this.state.propA)}>Prop A</button>
<button onClick={e => this.setState({propB: e.target.value}, () => props.onChangePropB(this.state.propB)}>Prop B</button>
</div>
Versus how I think I need to do it using hooks. The main problem I'm seeing is that there is no callback option for after the state change has completed. So I have to detect it in the useEffect and figure out which property just changed...
let prevPropA = props.propA;
let prevPropB = props.propB;
const [propA, setPropA] = useState(props.propA);
const [propB, setPropB] = useState(props.propB);
useEffect(() => {
if (prevPropA != propA) props.onChangePropA(propA);
if (prevPropB != propB) props.onChangePropB(propB);
});
<div>
<button onClick={e => {prevPropA = propA; setPropA(e.target.value)}}>Prop A</button>
<button onClick={e => {prevPropB = propB; setPropB(e.target.value)}}>Prop B</button>
</div>
I see this method getting extremely cumbersome and messy. Is there a more robust/proper way to accomplish this?
Thanks
=============================================================
Below is updated sample code based on Shubham's answer and
Ryan's feedback. Shubham answered the question as asked, but
Ryan is suggesting I give a more thorough example to ensure
I'm giving the right info for the right answer.
Here is sample code that more closely follows my real world
situation... although still a simplified example.
The parent component manages comments from users. Imagine
they can create new comments and select a date or a date-range.
They can also update existing comments. I have put the date
and date-range selector in its own component.
Therefore the parent comment manager component needs the ability
to create/load comments and pass the associated date(s) down to the
date-selector component. The user can then change the date(s)
and those values need to be propagated back up to the parent comment
manager to later be sent to the server and saved.
So you see, there is a bidirectional flow of property values (dates, etc)
that can be changed at any time from either end.
NOTE: This new example is updated using a method similar to what
Shubham suggested based on my original question.
=============================================================
const DateTimeRangeSelector = (props) =>
{
const [contextDateStart, setContextDateStart] = useState(props.contextDateStart);
const [contextDateEnd, setContextDateEnd] = useState(props.contextDateEnd);
const [contextDateOnly, setContextDateOnly] = useState(props.contextDateOnly);
const [contextDateHasRange, setContextDateHasRange] = useState(props.contextDateHasRange);
useEffect(() => { setContextDateStart(props.contextDateStart); }, [ props.contextDateStart ]);
useEffect(() => { if (contextDateStart !== undefined) props.onChangeContextDateStart(contextDateStart); }, [ contextDateStart ]);
useEffect(() => { setContextDateEnd(props.contextDateEnd); }, [ props.contextDateEnd ]);
useEffect(() => { if (contextDateEnd !== undefined) props.onChangeContextDateEnd(contextDateEnd); }, [ contextDateEnd ]);
useEffect(() => { setContextDateOnly(props.contextDateOnly); }, [ props.contextDateOnly ]);
useEffect(() => { if (contextDateOnly !== undefined) props.onChangeContextDateOnly(contextDateOnly); }, [ contextDateOnly ]);
useEffect(() => { setContextDateHasRange(props.contextDateHasRange); }, [ props.contextDateHasRange ]);
useEffect(() => { if (contextDateHasRange !== undefined) props.onChangeContextDateHasRange(contextDateHasRange); }, [ contextDateHasRange ]);
return <div>
<ToggleButtonGroup
exclusive={false}
value={(contextDateHasRange === true) ? ['range'] : []}
selected={true}
onChange={(event, value) => setContextDateHasRange(value.some(item => item === 'range'))}
>
<ToggleButton value='range' title='Specify a date range' >
<FontAwesomeIcon icon='arrows-alt-h' size='lg' />
</ToggleButton>
</ToggleButtonGroup>
{
(contextDateHasRange === true)
?
<DateTimeRangePicker
range={[contextDateStart, contextDateEnd]}
onChangeRange={val => { setContextDateStart(val[0]); setContextDateEnd(val[1]); }}
onChangeShowTime={ val => setContextDateOnly(! val) }
/>
:
<DateTimePicker
selectedDate={contextDateStart}
onChange={val => setContextDateStart(val)}
showTime={! contextDateOnly}
/>
}
</div>
}
const CommentEntry = (props) =>
{
const [activeComment, setActiveComment] = useState(null);
const createComment = () =>
{
return {uid: uuidv4(), content: '', contextDateHasRange: false, contextDateOnly: false, contextDateStart: null, contextDateEnd: null};
}
const editComment = () =>
{
return loadCommentFromSomewhere();
}
const newComment = () =>
{
setActiveComment(createComment());
}
const clearComment = () =>
{
setActiveComment(null);
}
return (
<div>
<Button onClick={() => newComment()} variant="contained">
New Comment
</Button>
<Button onClick={() => editComment()} variant="contained">
Edit Comment
</Button>
{
activeComment !== null &&
<div>
<TextField
value={(activeComment) ? activeComment.content: ''}
label="Enter comment..."
onChange={(event) => { setActiveComment({...activeComment, content: event.currentTarget.value, }) }}
/>
<DateTimeRangeSelector
onChange={(val) => setActiveComment(val)}
contextDateStart={activeComment.contextDateStart}
onChangeContextDateStart={val => activeComment.contextDateStart = val}
contextDateEnd={activeComment.contextDateEnd}
onChangeContextDateEnd={val => activeComment.contextDateEnd = val}
contextDateOnly={activeComment.contextDateOnly}
onChangeContextDateOnly={val => activeComment.contextDateOnly = val}
contextDateHasRange={activeComment.contextDateHasRange}
onChangeContextDateHasRange={val => activeComment.contextDateHasRange = val}
/>
<Button onClick={() => clearComment()} variant="contained">
Cancel
</Button>
<Button color='primary' onClick={() => httpPostJson('my-url', activeComment, () => console.log('saved'))} variant="contained" >
<SaveIcon/> Save
</Button>
</div>
}
</div>
);
}

useEffect takes a second argument which denotes when to execute the effect. You can pass in the state value to it so that it executes when state updates. Also you can have multiple useEffect hooks in your code
const [propA, setPropA] = useState(props.propA);
const [propB, setPropB] = useState(props.propB);
useEffect(() => {
props.onChangePropA(propA);
}, [propA]);
useEffect(() => {
props.onChangePropB(propB);
}, [propB]);
<div>
<button onClick={e => {setPropA(e.target.value)}}>Prop A</button>
<button onClick={e => {setPropB(e.target.value)}}>Prop B</button>
</div>

Related

React: state of states

React Hook "useState" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function
I understand why I am getting that message, but at the same time I think what I want should be achievable. Here's the code:
import React, { useState } from "react";
import "./styles.css";
export default function App() {
const children = useState([]);
return (
<div>
{children.map((child, i) => (<Child child={child} />))}
<button
onClick={() => { children[1]([...children, useState(0)]); }}
>
Add
</button>
</div>
);
}
function Child(props) {
const [state, setState] = props.child;
return (
<div>
<input
type="range"
min="1"
max="255"
value={state}
onChange={(e) => setState(e.target.value)}
></input>
</div>
);
}
I want each <Child> to be in complete control of its state without having to declare a function of type updateChild(i : Index, data) then finding the right children in the list, etc. As this doesn't scale well with deep nested view hierarchies.
Is there a way to both:
Keep the state in the parent (single source of truth)
Allow children to mutate their own state
In a nutshell, I want to achieve this but with more than one child component.
To illustrate the problem with your current approach and why React won't let you do that, here's an ersatz implementation of useState that has approximately the same behaviour as the real version (I've left triggering a re-render as an exercise to the user and it doesn't support functional updates, but the important thing here was showing the underlying state of the Parent component).
// Approximation of useState
let parentStateIndex;
const parentState = [];
const useState = (defaultValue) => {
const i = parentStateIndex;
if (parentState.length === i) {
parentState.push(defaultValue);
}
parentStateIndex += 1;
return [parentState[i], (value) => parentState[i] = value];
}
const Parent = () => {
parentStateIndex = 0; // hack required to reset on each "render"
const [state, setState] = useState([]);
return (
<>
{state.map((child) => (
<Child child={child} />
)}
<button
onClick={() => setState([...state, useState(0)])}
>
Add
</button>
</>
);
};
const Child = ({ child }) => {
const [state, setState] = child;
return (
<input
type="range"
min="0"
max="255"
value={state}
onChange={({ target }) => setState([target.value, setState])}
/>
);
};
(Note a bit of knowledge about the parent state has already crept into the child here - if it only setState(target.value) then the [value, setter] pair would be replaced by just value and other things would start exploding. But I think this way around gives a better illustration of what happens further down.)
The first time the parent is rendered, the new array passed to useState is added to the state:
parentState = [
[
[],
(value) => parentState[0] = value
],
];
All good so far. Now imagine the Add button is clicked. useState is called again, and the state is updated to add a second item and add that new item to the first item:
parentState = [
[
[
[0, (value) => parentState[1] = value]
],
(value) => parentState[0] = value,
],
[
0,
(value) => parentState[1] = value,
],
];
This also seems to have worked, but now what happens when the child value updates to e.g. 1?
parentState = [
[
[
[0, /* this gets called: */ (value) => parentState[1] = value],
],
(value) => parentState[0] = value,
],
[
/* but this gets changed: */ 1,
(value) => parentState[1] = value,
],
];
The second part of the state is updated, which changes its type completely, but the first one still holds the old value.
When the parent re-renders, useState is is only called once, so the second item in the parent state is irrelevant; the child gets the old value parentState[0][0], which is still [0, () => ...].
When the Add button gets clicked again, because that's only the second time useState gets called on this render, now we get the new value that was intended for the first child, as the second child:
parentState = [
[
[
[0, (value) => parentState[1] = value],
[1, (value) => parentState[1] = value],
],
(value) => parentState[0] = value,
],
[
1,
(value) => parentState[1] = value,
],
];
And, as you can see, changes to either the first or second child both target the same value; they would not actually appear anywhere until the Add button was clicked again and they'd suddenly be the value of the third child.
For more on how hooks work and why the call order is so important, see e.g. "Why Do React Hooks Rely on Call Order?" by Dan Abramov.
So what would work? For the child to be able to correctly update its parent's state, it needs at least the setter and its own index:
const Parent = () => {
const [state, setState] = useState([]);
return (
<>
{state.map((child, index) => (
<Child child={child} index={index} setState={setState} />
)}
<button
onClick={() => setState([...state, 0])}
>
Add
</button>
</>
);
};
const Child = ({ child, index, setState }) => {
return (
<input
type="range"
min="0"
max="255"
value={child}
onChange={({ target }) => setState((oldState) => {
return oldState.map((oldValue, i) => i === index
? target.value
: oldValue);
})}
/>
);
};
But that means the parent no longer controls its own state, and the child has to know all about its parent's state - those two components are very closely coupled, so the boundary between them is clearly in the wrong place (and maybe shouldn't exist at all).
So what's the correct solution? It's the thing you didn't want to do, having the child "phone home" with an updated value and letting the parent update its state accordingly. This keeps the child decoupled from the details of the parent and its implementation correspondingly simple:
const Parent = () => {
const [state, setState] = useState([]);
const onChange = (newValue, index) => setState((oldState) => {
return oldState.map((oldValue, i) => i === index
? newValue
: oldValue);
};
return (
<>
{state.map((child, index) => (
<Child child={child} onChange={(value) => onChange(value, index)} />
)}
<button
onClick={() => setState([...state, 0])}
>
Add
</button>
</>
);
};
const Child = ({ child, onChange }) => {
return (
<input
type="range"
min="0"
max="255"
value={child}
onChange={({ target: { value } }) => onChange(value)}
/>
);
};

How to get a value from onChange in react.js?

I'm trying to get value from onChange using setState but for some reason when I write text on input I get an error like Axis.map is not a function
Also,I'd like to delete Axisdata singly from the last one using splice or pop but whenever I click the delete button the Axis data disappeared except the first one.
Set Elements
const SetElements = ({
...
}) => {
const [Axis, setAxis] = useState([]);
const AxisHandler = e => {
setAxis([
...Axis,
{
label: "",
data: "",
backgroundColor: "",
},
]);
};
const deleteAxis = () => {
setAxis(Axis.splice(-1, 1));
};
return (
<>
<button onClick={AxisHandler}>add Line</button>
{Axis.length !== 1 && (
<button onClick={deleteAxis}>delete Line</button>
)}
{Axis.map((element, index) => (
<>
<AppendingAxis
Axis={Axis}
setAxis={setAxis}
element={element}
index={index}
/>
</>
))}
</>
)
AppendingAxis
const AppendingAxis = ({
index,
setAxis,
Axis,
}) => {
console.log(Axis);
return (
<AxisSetting>
<h4>{index + 2}Y Axis setting</h4>
<span>
<input
placeholder={index + 2 + "setting"}
type="textarea"
onChange={e => setAxis((Axis[index].label = e.target.value))}
/>
</span>
The issue is state mutation in the AppendingAxis component.
onChange={e => setAxis((Axis[index].label = e.target.value))}
You should shallow copy state, and nested state, then update specific properties.
onChange={e => setAxis(Axis => Axis.map((el, i) => i === index
? {
...el,
label: e.target.value
}
: el,
)}
I'm not a fan of passing the state updater function on to children as this make it the child component's responsibility to maintain your state invariant. I suggest moving this logic into the parent component so it can maintain control over how state is updated.
SetElements parent
const changeHandler = index => e => {
const { value } = e.target;
setAxis(Axis => Axis.map((el, i) => i === index
? {
...el,
label: value
}
: el,
);
};
...
<AppendingAxis
Axis={Axis}
onChange={changeHandler(index)}
/>
AppendingAxis child
const AppendingAxis = ({ Axis, onChange }) => {
console.log(Axis);
return (
<AxisSetting>
<h4>{index + 2}Y Axis setting</h4>
<span>
<input
placeholder={index + 2 + "setting"}
type="textarea"
onChange={onChange}
/>
</span>
And for completeness' sake, your delete handler looks to also have a mutation issue.
const deleteAxis = () => {
setAxis(Axis.splice(-1, 1));
};
.splice mutates the array in-place and returns an array containing the deleted elements. This is quite the opposite of what you want I think. Generally you can use .filter or .slice to generate new arrays and not mutate the existing one.
const deleteAxis = () => {
setAxis(Axis => Axis.slice(0, -1)); // start at 0, end at second to last
};
This is happening because of this line:
onChange={e => setAxis((Axis[index].label = e.target.value))}
Create a function:
const handleAxisChange = (e, index) => {
Axis[index].label = e.target.value;
setAxis(new Array(...Axis));
}
And then change set the onChange like this:
onChange={e => handleAxisChange(e, index)}
Your problem is because of you don't mutate state correctly. You should make a shallow copy of the state. You can change AppendingAxis to this code:
const AppendingAxis = ({
index,
setAxis,
Axis,
}) => {
console.log(Axis);
const onChange = (e,index)=>{
let copy = [...Axis];
copy[index].label = e.target.value;
setAxis(copy);
}
return (
<AxisSetting>
<h4>{index + 2}Y Axis setting</h4>
<span>
<input
placeholder={index + 2 + "setting"}
type="textarea"
onChange={e => onChange(e,index))}
/>
</span>

Update child component when parent state is updated

When there is a new update in my parent component, which occur within the function updateItemValue() , the items state will only get updated in parent. The child component won't get updated in an instance. It only get updated when the updateItemValue() get triggered twice.
It's something like:
1st round, parent update the items, child didn't receive the update.
2nd round, parent update the items, child only receive the update from 1st round
Parent Component
const SelectItem = () => {
const { items } = useSelector(state => state.itemsReducer);
const [ itemUpdate, setItemUpdate ] = useState(false);
const [ group_id, setGroupId ] = useState(false);
useEffect(() => {
updateItemValue()
}, [itemUpdate])
function updateItemValue(){
// original items value
/*
[
{"_id":1, "name":"Item 1", "description":"Item 1 description"},
{"_id":2, "name":"Item 2", "description":"Item 2 description"},
{"_id":3, "name":"Item 3", "description":"Item 3 description"}
]
*/
// step 3 : now this function get called because the group_id is updated
items.forEach((item, i) => {
if (group_id == item.group_id) {
// step 4 : update the value of items state, add extra data, once this updated, I expect the new items value to be passed to child component
items[i].extra = true;
}
});
}
// step 2 : update the group_id and set the itemUpdate to true to trigger useEffect
function selectGroup(group_id){
setItemUpdate(true)
setGroupId(group_id)
}
return(
<div>
{/* step 1: select option */}
<button onClick={() => selectGroup(1)}>Option 1</button>
<button onClick={() => selectGroup(2)}>Option 2</button>
{
items.map((item) => (
<ItemCard
key={item._id}
id={item._id}
name={item.name}
description={item.description}
onClick={(value) => updateSelectedItem(value)}
/>
))
}
</div>
)
}
Child Component
export default function ItemCard({id, name, description, onClick, extra}){
console.log(extra) // the value is undefined when selectGroup() 1st triggered from parent
return (
<div onClick={() => onClick(id)}>
<div>
<p>{name}</p>
<p>{description}</p>
{ extra ? '<p>Extra item is required</p>' : null }
</div>
</div>
)
}
p/s : the state items came from redux state. So the state update at updateItemValue() is to add some extra value which I want to use at child component.
Issue
You are mutating an object reference
function updateItemValue(){
items.forEach((item, i) => {
if (group_id == item.group_id) {
items[i].extra = true; // <-- state object mutation!
}
});
}
Solution
Looks like you are using Redux since you are using the useSelector react hook.
const { items } = useSelector(state => state.itemsReducer);
I'll assume you have access to a useDispatch hook and action to dispatch to update your state as well.
You can likely simplify your component code a bit, and move the update logic to the reducer so stat is correctly updated.
const SelectItem = () => {
const { items } = useSelector((state) => state.itemsReducer);
const dispatch = useDispatch();
// step 2: dispatch action to update state via the group_id
function selectGroup(groupId) {
dispatch({ type: "UPDATE_GROUP", groupId });
}
return (
<div>
{/* step 1: select option */}
<button onClick={() => selectGroup(1)}>Option 1</button>
<button onClick={() => selectGroup(2)}>Option 2</button>
{items.map((item) => (
<ItemCard
key={item._id}
id={item._id}
name={item.name}
description={item.description}
onClick={(value) => updateSelectedItem(value)}
/>
))}
</div>
);
};
I'm going to just guess at the reducer function now, it may be similar to the following.
const initialState = {
items: [],
}
const itemsReducer = (state = initialState, action) => {
switch (action.type) {
...
// step 3: update items array by matching item group id
case "UPDATE_GROUP":
return {
...state,
items: state.items.map((item) =>
item.group_id === action.groupId
? {
...item,
extra: true
}
: item
)
};
...
default:
return state;
}
};
Additional Suggestion
You can also simplify the child component ItemCard a bit. Direct attach the click handler and simplify the conditional rendering. Also, I don't know if it was intentional (I think not), but you may not want the string literal "<p>Extra item is required</p>" rendering.
function ItemCard({ name, description, onClick, extra }) {
return (
<div onClick={onClick}>
<div>
<p>{name}</p>
<p>{description}</p>
{extra && <p>Extra item is required</p>}
</div>
</div>
);
}
And update the mapping in the parent. Don't pass an id prop, but pass the item._id to the updateSelectedItem callback.
{items.map((item) => (
<ItemCard
key={item._id}
name={item.name}
description={item.description}
onClick={() => updateSelectedItem(item._id)}
/>
))}

setState inside setTimeout react hooks

I am currently trying to build a rock-paper-scissor and what I intend to achieve are this logic:
after the start button clicked, a player has 3seconds to pick a weapon, if not, a random weapon will be picked for the player.
The problem:
When I picked a weapon under the 3seconds, it works just fine. But, when I intentionally let the setTimeout triggered, it is not updating the state automatically. I suspected the if conditions are not met, but I don't know why that happen.
Here is the code snippet:
//custom hooks//
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
const weapons= ['rock', 'weapon', 'scissors']
const App = () => {
const [p1Weapon, setp1Weapon] = useState("");
const prevWeapon = usePrevious(p1Weapon);
const getPlayerTimeout = (playerRef, setPlayer, time) => {
setTimeout(() => {
if (playerRef === "") {
setPlayer(weapons[Math.floor(Math.random() * weapons.length)]);
}
}, time);
};
const startGame = () => {
getPlayerTimeout(prevWeapon, setp1Weapon, 3000);
}
return (
...
<div>
<button
className="weaponBtn"
onClick={() => {
setp1Weapon("rock");
}}
>
rock
</button>
<button className="weaponBtn" onClick={() => setp1Weapon("paper")}>
paper
</button>
<button className="weaponBtn" onClick={() => setp1Weapon("scissors")}>
scissor
</button>
<button type="button" onClick={startGame}>
Start!
</button>
</div>
)
Thanks!
if all you want to do is set a state after x time you can do this very easily like this
this.setState({isLoading: true},
()=> {window.setTimeout(()=>{this.setState({isLoading: false})}, 8000)}
);
this should set the value of isLoading to false after 8 seconds.
I hope it helps

How to edit these parts of the code without data tags and how to edit the methods relevant to them?

There is a code of my main application file - App.js. I'll write it at the bottom of the question.
And my code reviewer told me that in it I need to change these two parts in the file App.js:
First part:
{daysArray.map((day, i) => (
<button key={day} onClick={() => this.changeDay(i)} className={i === this.state.day ? "active" : ""}>{day}
</button>
))}
Second part:
{Object.keys(startDataArray).map(item => (
<button data-category={item} onClick={this.changeCategory} className={item === category ? "active" : ""}>{item} category</button>
))}
method to the first part:
changeDay = (argDay) => {
this.setState({
day: argDay
},this.fetchData);
};
method to the second part:
changeCategory = (event) => {
this.setState({
category: event.target.dataset.category
},this.filter);
};
He said that it was not need to use any data tags (that is, not to use key and data-category, and demonstrated an example on another code (Redux + React) as needed to be edit.
Here is an example:
from this code:
{data && Object.keys(data).map(n => (
<button data-shift={n} onClick={e => onFilter({ shift: e.target.dataset.shift })} className={n === shift ? "active" : "noActive"}>
{n}
</button>
))}
edit to this:
{data && Object.keys(data).map(n => (
<button onClick={()=> onFilter({ shift: n })} className={n === shift ? "active" : "noActive"}>
{n}
</button>
))}
I tried, by analogy with the example of the person who checked (Redux-React), to edit my code without data tags and removed the dataset and other unnecessary ones in the methods themselves. But unfortunately nothing works. How to edit these parts of the code without data tags and the relevant methods to make it work?
and the App.js file code itself:
const daysArray = ["yesterday", "today", "tomorrow"];
class App extends React.Component {
state = {
day: 1,
startDataArray: [],
filteredDataArray: [],
searchInput: "",
category: "first"
};
componentDidMount() {
this.fetchData();
}
fetchData = async () => {
const response = await fetch(`/data/${daysArray[this.state.day]}.json`);
const data = (await response.json()).group;
this.setState({
startDataArray: data,
category: Object.keys(data)[0]
},this.filter);
};
changeDay = (argDay) => {
this.setState({
day: argDay
},this.fetchData);
};
updateSearchInput = (e) => {
this.setState({
searchInput: e.target.value
});
};
filter = () => {
this.setState(
({ searchInput, startDataArray, category}) => {
return {
filteredDataArray: startDataArray[category].filter(item =>
item.firstName.toLowerCase().includes(searchInput.toLowerCase())
)
};
}
);
};
changeCategory = (event) => {
this.setState({
category: event.target.dataset.category
},this.filter);
};
render() {
const {searchInput, category, startDataArray, filteredDataArray} = this.state;
return (
<div>
<TableSearch value={searchInput} onChange={this.updateSearchInput} onSearch={this.filter}/>
{daysArray.map((day, i) => (
<button key={day} onClick={() => this.changeDay(i)} className={i === this.state.day ? "active" : ""}>{day}</button>
))}
<br />
{Object.keys(startDataArray).map(item => (
<button data-category={item} onClick={this.changeCategory} className={item === category ? "active" : ""}>{item} category</button>
))}
<TableData dataAttribute={filteredDataArray} />
</div>
);
}
}
tag <button> has attribute value. You can pass the item (I hope item is id) to value. And get value from event.currentTarget.value on listener changeCategory

Resources