-1

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.

frankelot
  • 13,666
  • 16
  • 54
  • 89
  • Unless the child was also given access to the array and told its own index (i.e. got _all_ the arguments passed to the map callback) there's no way it can update the parent's array. Even then it would be mutating the array, which React wouldn't detect to re-render the parent, so only the child would be re-rendered. The correct solution is to pass a callback to inform the parent of changes, and it's unclear _why_ you don't want to do it. – jonrsharpe Nov 10 '22 at 08:46
  • > The correct solution is to pass a callback In a way, that's exactly what I want to do `setState` is itself a callback. I'm trying to avoid creating a new one. I think it would simplify things. > there's no way it can update the parent's array Since I'm passing a unique callback for each child, no index would be needed – frankelot Nov 10 '22 at 08:51
  • I think, without registering the children somehow in a central array, it will not go. But that does not mean, that all your states have to go into that centraly storage or that you need an extra fuction. Just the setState functions have to go there. Have a look at my question here: https://stackoverflow.com/questions/74166211/changeing-state-of-one-component-from-another-component ! I think, this could help you. – habrewning Nov 10 '22 at 08:51
  • Yes `setState` is a function, but to invoke it correctly you need to know the new value and the index at which it should be, and to use that to create a new version of the array with the new value at the appropriate index. So either that's defined in all of the children, which are also told their own indexes, and the parent loses control of its own state, or you keep it where the state is owned and expose a much simpler interface to the child, which can then just do `onChange={({ target }) => onChange(target.value)}`. And either way `children[1]([...children, useState(0)]);` isn't helpful. – jonrsharpe Nov 10 '22 at 09:00
  • > And either way children[1]([...children, useState(0)]); isn't helpful I think we're potentially talking about different things. Clicking the button adds a new 'value' to the `children` state. That 'value' is, in itself, also a useState so eventually `children` state will look like `[(state, setState), (state, setState), (state, setState), ..., ...]` Each one of these `setState` lambdas is then passed down to each respective child. There's no need to know the index. – frankelot Nov 10 '22 at 09:12
  • here's an example of what I'm doing but using just one children: https://stackoverflow.com/a/57144563/1949554 See how an extra function is not needed here – frankelot Nov 10 '22 at 09:16
  • Yes I can see what you're trying to do, an array of `[childXState, updateChildXState]` but that profoundly misunderstands what the hooks are doing. The example you've linked to works because the child gets the whole state of its parent. In this case you could give the child the whole state but then you also have to tell it which index it's at. And in both cases it's a bad idea from a coupling perspective. – jonrsharpe Nov 10 '22 at 10:02
  • It's not sending the whole state of the parent, the parent could have multiple `useState` which won't be passed down. The reason I'm against using a lambda callback is, it doesn't scale well. What If I need to hoist my state 3 or 4 components up all of the sudden? – frankelot Nov 10 '22 at 10:06
  • _"It's not sending the whole state of the parent"_ - and that's why it can't work. _"What If I need to hoist my state 3 or 4 components up"_ - then maybe the design was wrong to start with, but is that the problem you actually have? I suspect this is an [XY problem](https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem). I've explained below the flaw in your current approach, which hopefully provides a much better mental model of the state of a function-based component, but whether that actually solves the underlying issue isn't clear. – jonrsharpe Nov 10 '22 at 10:15

1 Answers1

1

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)}
    />
  );
};
jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
  • Thanks for taking the time to write this! Super appreciated. A couple of questions: Isn't the problem in your example that the `index` variable is shared by the `outter` and `inner` states? Also, `onClick={() => setState([...state[0], useState(0)])} ` should be `onClick={() => setState([...state, useState(0)])}` to avoid the state nesting you show in the example. I'm having some trouble wrapping my head around it honestly. I'll take your word for it and use the callback approach even if I don't love it. – frankelot Nov 10 '22 at 11:02
  • @frankelot 1. The shadowing was unhelpful, I've edited to distinguish the underlying `parentState` that the "hook" manages from the local variables in the components. 2. I don't have `[...state[0], useState(0)]` anywhere. – jonrsharpe Nov 10 '22 at 11:57
  • You're still using the same "set" lambda `(value) => parentState[i] = value];` for all `useState`s which mutate the same `parentState` array. Shoulnd't each `useState()` get their own state (array or value) – frankelot Nov 10 '22 at 12:57
  • @frankelot the whole point of the `parentState` array is to illustrate (roughly) how the `useState` hook works behind the scenes. If I was to change that, such that each call was independent and the order didn't matter, then it would be completely useless as a tool to demonstrate the problem. – jonrsharpe Nov 10 '22 at 15:25