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)}
/>
);
};