简体   繁体   中英

Closure on the result of a `useState` hook causes annoying behaviour

I have a component like this:

const MyInput = ({ defaultValue, id, onChange }) => {

  const [value, setValue] = useState(defaultValue);
  const handleChange = (e) => {
    const value = e.target.value;
    setValue(value);

    onChange(id, value);
  };

  return (
    <div>
      <input type="text" value={value} onChange={handleChange} />
    </div>
  );
};

Where I want to it to behave like an uncontrolled component, but I want it to have an initial value that we can set.

In my parent component this works fine,

export default function App() {
  const [formState, setFormState] = useState({});

  const handleChange = (id, value) => {
    console.log(id, value, JSON.stringify(formState)); // For debugging later
    setFormState({ ...formState, [id]: value });
  };


  return (
    <div className="App">
      <pre>{JSON.stringify(formState, null, 2)}</pre>
      <MyInput id="1" defaultValue="one" onChange={handleChange} />
      <MyInput id="2" defaultValue="two" onChange={handleChange} />
      <MyInput id="3" defaultValue="three" onChange={handleChange} />
      <MyInput id="4" defaultValue="four" onChange={handleChange} />
      <MyInput id="5" defaultValue="five" onChange={handleChange} />
    </div>
  );
}

but the problem is that the value of formState doesn't reflect the values on the MyInputs until they fire an onchange event.

So ok, I can just add a 'component did mount' useEffect hook on the MyInput to fire an onChange when the component first mounts.

const MyInput = ({ defaultValue, id, onChange }) => {
  useEffect(() => {
    onChange(id, defaultValue);
  }, []);

  const [value, setValue] = useState(defaultValue);
  const handleChange = (e) => {
    const value = e.target.value;
    setValue(value);

    onChange(id, value);
  };

  return (
    <div>
      <input type="text" value={value} onChange={handleChange} />
    </div>
  );
};

Code sandbox

But this doesn't behave how we want. Each of the MyInputs do call onChange when they first mount, but reference to formState is stale for the main onChange handler in each case.

ie. the console.log output when the page first loads is:

1 one {}
2 two {}
3 three {}
4 four {}
5 five {}

So I've thought that using a ref could solve this. eg:

export default function App() {
  const [formState, setFormState] = useState({});

  const formStateRef = useRef(formState);

  useEffect(() => {
    formStateRef.current = formState;
  }, [formState]);

  const handleChange = (id, value) => {
    console.log(
      id,
      value,
      JSON.stringify(formState),
      JSON.stringify(formStateRef)
    );
    setFormState({ ...formStateRef.current, [id]: value });
  };

  return (
    <div className="App">
      <pre>{JSON.stringify(formState, null, 2)}</pre>
      <MyInput id="1" defaultValue="one" onChange={handleChange} />
      <MyInput id="2" defaultValue="two" onChange={handleChange} />
      <MyInput id="3" defaultValue="three" onChange={handleChange} />
      <MyInput id="4" defaultValue="four" onChange={handleChange} />
      <MyInput id="5" defaultValue="five" onChange={handleChange} />
    </div>
  );
}

Code Sandbox

This doesn't work. It appears that all of the change handlers fire before useEffect on formState has the chance to update the ref.

1 one {} {"current":{}}
2 two {} {"current":{}}
3 three {} {"current":{}}
4 four {} {"current":{}}
5 five {} {"current":{}}

I guess I could directly mutate the ref in the handleChange function, and set that?

export default function App() {
  const [formState, setFormState] = useState({});

  const formStateRef = useRef(formState);

  const handleChange = (id, value) => {
    console.log(
      id,
      value,
      JSON.stringify(formState),
      JSON.stringify(formStateRef)
    );

    formStateRef.current = { ...formStateRef.current, [id]: value };
    setFormState(formStateRef.current);
  };

  return (
    <div className="App">
      <pre>{JSON.stringify(formState, null, 2)}</pre>
      <MyInput id="1" defaultValue="one" onChange={handleChange} />
      <MyInput id="2" defaultValue="two" onChange={handleChange} />
      <MyInput id="3" defaultValue="three" onChange={handleChange} />
      <MyInput id="4" defaultValue="four" onChange={handleChange} />
      <MyInput id="5" defaultValue="five" onChange={handleChange} />
    </div>
  );
}

Code Sandbox

This does work. But this use of refs makes me a bit uneasy.

What's the standard way to achieve something like this?

If you take your first example and simply use a functional state update instead I think you'll achieve what you are after.

Issue

With the the non-functional update, all the inputs mount at the same time and all invoke their onChange handlers. The handleChange callback uses the state from the render cycle it was enclosed in. When all inputs enqueue updates in the same render cycle, each subsequent update overwrites the previous state update. The last input to update state is the one that "wins". This is why you see:

{
  "5": "five"
}

Instead of

{
  "1": "one",
  "2": "two",
  "3": "three",
  "4": "four",
  "5": "five"
}

Solution

Use a functional state update to correctly update from the previous state, not the state from the previous render cycle.

setFormState(formState => ({ ...formState, [id]: value }));

From here suggestions are only optimizations

  1. Curry the input id in the handler, makes for one less props to pass and process. The children inputs also don't need to know it to handle changes.

     const handleChange = (id) => (value) => { setFormState((formState) => ({...formState, [id]: value })); };
  2. Pass defaultValue to the defaultValue prop of the underlying inputs.

     const MyInput = ({ defaultValue, onChange }) => { useEffect(() => { onChange(defaultValue); }, []); const handleChange = (e) => { const { value } = e.target; onChange(value); }; return ( <div> <input type="text" defaultValue={defaultValue} onChange={handleChange} /> </div> ); };
  3. Pass the id in the curried handler

     <MyInput defaultValue="one" onChange={handleChange(1)} /> <MyInput defaultValue="two" onChange={handleChange(2)} /> <MyInput defaultValue="three" onChange={handleChange(3)} /> <MyInput defaultValue="four" onChange={handleChange(4)} /> <MyInput defaultValue="five" onChange={handleChange(5)} />

Demo

编辑closure-on-the-result-of-a-usestate-hook-causes-annoying-behaviour

demo code

const MyInput = ({ defaultValue, onChange }) => {
  useEffect(() => {
    onChange(defaultValue);
  }, []);

  const handleChange = (e) => {
    const { value } = e.target;
    onChange(value);
  };

  return (
    <div>
      <input type="text" defaultValue={defaultValue} onChange={handleChange} />
    </div>
  );
};

export default function App() {
  const [formState, setFormState] = useState({});

  const handleChange = (id) => (value) => {
    setFormState((formState) => ({ ...formState, [id]: value }));
  };

  return (
    <div className="App">
      <pre>{JSON.stringify(formState, null, 2)}</pre>
      <MyInput defaultValue="one" onChange={handleChange(1)} />
      <MyInput defaultValue="two" onChange={handleChange(2)} />
      <MyInput defaultValue="three" onChange={handleChange(3)} />
      <MyInput defaultValue="four" onChange={handleChange(4)} />
      <MyInput defaultValue="five" onChange={handleChange(5)} />
    </div>
  );
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM