2

I have two inputs whose values reflect the state of a counter.

The first input sets the state immediately as its value changes. This input can represent other parts of the application changing state.

The second input part of a component I made named LazyInput. LazyInput doesn't update state immediately, only when it's blurred, which I think gives a better usability. This input can represent the user input, like a timer to be set or the position of something on the screen.

However, I would like the LazyInput not to behave "lazily" and actually update the counter immediately only when the value is changed via the keys.

The App code (which includes the normal input) is the following:

const {Fragment, useState, useEffect} = React;

function LazyInput({ name, value, onComplete }) {
  const initialValue = value;
  const [lazyValue, setLazyValue] = useState(value);

  // Sync to state changed by the regular input
  useEffect(() => {
    setLazyValue(value);
  }, [value]);

  const handleKeyDown = e => {
    const { key, target } = e;
    switch (key) {
      case "ArrowUp": {
        setLazyValue(parseFloat(lazyValue) + 1);
        onComplete(e);
        break;
      }
      case "ArrowDown": {
        setLazyValue(parseFloat(lazyValue) - 1);
        onComplete(e);
        break;
      }
      case "Escape": {
        setLazyValue(initialValue);
        target.blur();
        break;
      }
      case "Enter": {
        target.blur();
        break;
      }
      default:
        return;
    }
  };
  return (
    <input
      name={name}
      value={lazyValue}
      onChange={e => setLazyValue(e.target.value)}
      onKeyDown={e => handleKeyDown(e)}
      onBlur={e => onComplete(e)}
    />
  );
}

function App() {
  const [counter, setCounter] = useState(0);

  function handleCounterChange(e) {
    setCounter(parseFloat(e.target.value));
  }

  return (
    <Fragment>
      <div>Counter: {counter}</div>
      <LazyInput
        name="counter"
        value={counter}
        onComplete={e => handleCounterChange(e)}
      />
      <input
        value={counter}
        type="number"
        onChange={e => handleCounterChange(e)}
      />
    </Fragment>
  );
}

ReactDOM.render(<App />, document.getElementById("app"));
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>

<div id="app"></div>

The problem is: pressing the keys fires the onComplete(e) function before the setLazyValue() – I suppose because it's asynchronous – losing sync with the state.

For the same reason, pressing Esc blurs the input before resetting its value to initialValue.

I know the setState() class callback has been substituted by the useEffect() hook, but:

  1. How do I call the onComplete(e) function – like any other event – from useEffect()?
  2. How do I do that only when the keys are pressed, but not for all other keys?

Thanks in advance for you help, here's an interactive sandbox.

  • 1
    Could you add a little more information to your question. I don't think I'm understanding what you are asking for. Right now, when I press up the value in the input changes which is what it sounds like you want in the lazy one. If I press escape it resets and enter blurs. – zero298 Feb 07 '20 at 18:39
  • 2
    Also, I have tried to convert your example to a Runnable Snippet here on the site. Feel free to roll back the change if it no longer produces the same results. – zero298 Feb 07 '20 at 18:47
  • Use only App state.LazyOnput state is unnecessary.lift up handleKeyDown – gadi tzkhori Feb 07 '20 at 18:49
  • You do not need the lazyValue state, that component can be stateless. Simply use the prop value in the LazyInput and assign it to the value of that input. Then onKeyDown/Up you call the parent callback passed as prop for resetting the state in the app component, this will update the parent value, which will be passed as prop, and thr component will render again showing the new value sync. P.s. to pass down setters, it is better to use useReducer iinstead of useState and pass the dispatch – quirimmo Feb 07 '20 at 18:54
  • @zero298 I hope my question is clearer now. It's maybe due to React's optimization, but it doesn't work on my setup. The input sure changes, but it's not in sync with state. On the other hand, pressing the Esc key **always** blurs the input before setting the state, not working at all for me. –  Feb 08 '20 at 11:58

1 Answers1

2

I would recommend passing counter and setCounter down to LazyInput so they can be accessed directly. useEffect can be given a dependency array where it will only fire if one of its dependencies changes. However, useEffect is not right for this situation. You could assign counter as a dependency of the useEffect but it would fire every time the value of counter is changed. useEffect can only watch variables, you can't pass values to it. I suggest reading this article for learning about how useEffect works and when to use it.

https://overreacted.io/a-complete-guide-to-useeffect/

My solution passes counter and setCounter to LazyInput. If you want LazyInput to retain its own state, you can use a useEffect to watch lazyValue. When lazyValue changes, the counter should be updated.

Here is my solution:

const {Fragment, useState, useEffect} = React;

function LazyInput({ name, counter, setCounter }) {
  const [initialValue] = useState(counter);

  const handleKeyDown = e => {
    const { key, target } = e;

    switch (key) {
      case "ArrowUp": {
        setCounter(counter + 1);
        break;
      }
      case "ArrowDown": {
        setCounter(counter - 1);
        break;
      }
      case "Escape": {
        setCounter(initialValue);
        target.blur();
        break;
      }
      case "Enter": {
        target.blur();
        break;
      }
      default:
        return;
    }
  };
  return (
    <input
      name={name}
      value={counter}
      onChange={e => setCounter(e.target.value)}
      onKeyDown={e => handleKeyDown(e)}
    />
  );
}

function App() {
  const [counter, setCounter] = useState(0);

  return (
    <Fragment>
      <div>Counter: {counter}</div>
      <LazyInput name="counter" counter={counter} setCounter={setCounter} />
      <input
        value={counter}
        type="number"
        onChange={e => setCounter(parseFloat(e.target.value))}
      />
    </Fragment>
  );
}

ReactDOM.render(<App />, document.getElementById("app"));
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>


<div id="app"></div>
Keith Denning
  • 29
  • 1
  • 9
  • 1
    you are calling setCounter with an array... (setCounter(initialValue); – quirimmo Feb 07 '20 at 19:10
  • Thanks I fixed it – Keith Denning Feb 07 '20 at 19:21
  • This way we lose the "lazy" aspect of `LazyInput`, which now updates the state every time its value changes. I think waiting for the input to be blurred gives a much better usability. –  Feb 08 '20 at 11:51
  • Then I recommend using a combination of the `useRef` hook (https://reactjs.org/docs/hooks-reference.html#useref) and a `useFocus` hook (https://codesandbox.io/s/72o0nwp86q). You can set a ref on the input and then apply the `useFocus` hook. `useFocus` returns a value that represents whether an element is focuses or not. That value can be placed in the dependency array for `useEffect`. When the input is blurred, perform an action. I can update the answer if you'd like. – Keith Denning Feb 11 '20 at 18:58