3

I am trying to write a hook that takes a (ref to a) input element and listens to updates on it. As it is done decoupled from the rendering, in a hook, I can not use the normal onInput prop directly on the element.

When updating unrelated state (using a useState hook) in the event listener the input element breaks in that you can not type into it and the caret/focus changes instead.

Why does addEventListener('input', handleInput) and onInput={handleInput} differ in this way in react and what is the best workaround for this issue?

Here is a snippet that reproduces the issue:

const Hello = (props) => {
  const inputRef = React.useRef();
  const [val, setVal] = React.useState("Can't type here");
  const [other, setOther] = React.useState(0);
  
  React.useEffect(() => {
    const inputEl = inputRef.current;
    
    const handleInput = (ev) => {
      console.log('In handleInput');
      setOther(prev => prev + 1)
    };
  
    inputEl.addEventListener('input', handleInput);
    return () => inputEl.removeEventListener('input', handleInput);
  }, []);
  
  return (
    <input 
      ref={inputRef} 
      value={val} 
      onChange={ev => setVal(ev.target.value)}
    />
  );
}

ReactDOM.render(
  <Hello />,
  document.getElementById("react")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>

<div id="react"></div>
Paso
  • 353
  • 2
  • 3
  • 13
  • I have worked around this by changing the setOther logic into using refs. I could also get it to somewhat work by wrapping the setOther in a setTimeout. These "solutions" doesn't however answer the question of why this weirdness exists (or if it's a bug) so I'll leave the question for a real answer. – Paso Sep 30 '21 at 13:54

1 Answers1

1

Your non-React event is triggering a state change setOther(prev => prev + 1) which is causing the component to re-render before the React event can take place.

I would generally recommend against using non-React events where possible, and definitely if you need their resulting code to affect the state.

If you remove the state change from the vanilla JS event handler the component should function as expected:

const Hello = (props) => {
  const inputRef = React.useRef();
  const [val, setVal] = React.useState("Can't type here");
  const [other, setOther] = React.useState(0);
  
  React.useEffect(() => {
    const inputEl = inputRef.current;
    
    const handleInput = (ev) => {
      console.log('In handleInput');
    };
  
    inputEl.addEventListener('input', handleInput);
    return () => inputEl.removeEventListener('input', handleInput);
  }, []);
  
  return (
    <input 
      ref={inputRef} 
      value={val} 
      onChange={ev => setVal(ev.target.value)}
    />
  );
}

ReactDOM.render(
  <Hello />,
  document.getElementById("react")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>

<div id="react"></div>

If you need both events, why not attach them both to the React event, like this (React onChange listens for input, rather then blur anyway):

const Hello = (props) => {
  const inputRef = React.useRef();
  const [val, setVal] = React.useState("Can't type here");
  const [other, setOther] = React.useState(0);
  
  return (
    <input 
      ref={inputRef} 
      value={val} 
      onChange={ev => {
        setVal(ev.target.value)
        console.log('In handleInput')
        setOther(prev => prev + 1)
      }}
    />
  );
}

ReactDOM.render(
  <Hello />,
  document.getElementById("react")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>

<div id="react"></div>
DBS
  • 9,110
  • 4
  • 35
  • 53
  • _"Your non-React event is triggering a state change `setOther(prev => prev + 1)` which is causing the component to re-render before the React event can take place"_ - Do you have any source (link to docs) that explains this behavior? State is NOT updated immediately, so I would expect `onChange` to be triggered. Even if state was updated immediately, I would still expect `onChange` to be triggered because there is a listener set for the change event. – Yousaf Sep 29 '21 at 10:23
  • Like Yousaf I wouldn't expect the re-render to affect this, both because it should run after the event, because a render shouldn't touch the event and also because it works when using the onInput-prop instead of addEventListener. – Paso Sep 30 '21 at 13:50