How to make React input onChange set state only after onChange stops firing for set time?
Asked Answered
R

3

9

Intro I have a user input element in a create-react-app app. It lets the user type a number, or use arrows to increment the number up or down. The output then displays this number.

enter image description here

Problem The user wants to arrive at a certain number, and no matter how they enter in the number, there are spurious intermediate values that trigger the onChange eventlistener. If the user tries to enter "15" with the keyboard, the output is first "1" and then "15", but I want it to just be "15". I do not want to use a button/submit/etc or other events. If the user presses and holds down the up-arrow from 0 to 500, I want to update directly to 500, after the 'fast-changing' inputs have slowed/stopped.

Goal I want to program an onChange function so that the output is only updated when the onChange has not fired an event for at least 600 ms since the last event.

Details If the user types '1' and then '5' ( for '15' ) fewer than 600ms apart, the output should update to '15', and skip '1'. But, if the user takes longer than 600ms between typing '1' and '5', then the output should first be '1' and then '5'. Likewise, if the user is pressing the increment arrows very fast up and down, the output should not update until they stop or slow down.

in the terminal, make a react app using create-react-app

create-react-app delay-output-app

Code copy and paste this into delay-output-app/src/App.js, replacing all previous code.

import React, { useState, useEffect, useRef } from "react";
import "./App.css";

function App() {
  const inputRef = useRef();
  const [userValue, setUserValue] = useState(0);
  const [output, setOutput] = useState(0);

  const updateOutput = () => {
    setUserValue(inputRef.current.value);
    setOutput(inputRef.current.value);
    //if input is changing quickly - do not update output
    //only update output if input has not changed for 700ms since last change.
  };

  return (
    <div className="App">
      <br></br>
      <div>
        <h1>Input</h1>
        <input
          ref={inputRef}
          value={userValue}
          type="number"
          onChange={updateOutput}
        ></input>
      </div>
      <br></br>
      <div>
        <h1>Output</h1>
        {output}
      </div>
    </div>
  );
}

export default App;

to run the app in the terminal...

cd delay-output-app
npm start

I have tried playing with setTimeout and setInterval, but I keep running into issues, especially when combined with react useEffect hook. Again, I don't want to use a button or have user need to press anything aside from typing the numbers or clicking arrows to update the output. Only time between input values dictates when an input value is intermediate or should update output.

update to include debounce - I'm under the impression that debounce shouldn't run until the input event is over, but I'm still getting update output after just the wait period, regardless of if the user input event is over.

code with lodash debounce

import React, { useState, useRef } from "react";
import "./App.css";
import _ from "lodash";

function App() {
  const inputRef = useRef();
  const [userValue, setUserValue] = useState(0);
  const [output, setOutput] = useState(0);

  const updateOutput = _.debounce(
    () => setOutput(inputRef.current.value),
    700
  );
  //if input is changing quickly - do not update output
  //only update output if input has not changed for 700ms since last change.

  const updateUserValue = () => {
    setUserValue(inputRef.current.value);
  };
  return (
    <div className="App">
      <br></br>
      <div>
        <h1>Input</h1>
        <input
          ref={inputRef}
          value={userValue}
          type="number"
          onChange={() => {
            updateOutput();
            updateUserValue();
          }}
        ></input>
      </div>
      <br></br>
      <div>
        <h1>Output</h1>
        {output}
      </div>
    </div>
  );
}

export default App;


Reichert answered 4/12, 2019 at 6:22 Comment(6)
If I'm understanding correctly, I think you'll want to debounce: lodash.com/docs/4.17.15#debounce your function(s). This will delay calling a callback function until after a set amount of time (700ms) has passed.Hysterical
@MattCarlotta I've used lodash, but never debounce - I'll look into it & Thank you.Reichert
Pretty sure lodash's debounce will do what you want. Give it a shot, if you still need help, I'll create a working codesandbox example.Hysterical
@MattCarlotta Yes, debounce worked! So many hours spent trying to make debounce myself! Would you like to answer the question so I can accept it? If not, I can put up the answer.Reichert
Go ahead and put up your solution for those who may stumble across the same problem.Hysterical
I got ahead of myself. I included lodash in update to my question (at end) but the problem still persists that if the user presses the arrow for longer than the wait time (user still not arrived at desired number) the output starts updating.Reichert
A
5

You don't really need a ref to solve this. A call touseEffect should be enough:

useEffect(() => {
  const timeout = setTimeout(() => {
    setOutput(userValue);
  }, 600);
  return () => {
    clearTimeout(timeout);
  };
}, [userValue]);

const updateOutput = (e) => {
  setUserValue(e.target.value);
};
Antung answered 4/12, 2019 at 8:42 Comment(1)
I didn't know the function returned by useEffect would be called every time a dependency changed. I thought it was only when the whole component unmounted. Valuable lesson.Reichert
J
2

You can try this way

const updateOutput = () => {
setUserValue(inputRef.current.value);
setTimeout(() => {setOutput(inputRef.current.value)}, 700) }

Hope this will help you.

Jaimie answered 4/12, 2019 at 6:30 Comment(2)
Thank you for your response. This does most of the trick. However, if the user holds the arrow down (going very fast) for longer than 700ms, then the output updates even though the user has still not stopped.Reichert
I will edit my question to make clear that it isn't just entering in from keyboard.Reichert
H
2

This ought to do the trick. You'll want to use a ref to persist the debounced function across repaints:

const { useCallback, useState, useRef } = React;
const { render } = ReactDOM;
const { debounce } = _;

function App() {
  const [userValue, setUserValue] = useState(0);
  const [output, setOutput] = useState(0);
  const debounced = useRef(debounce((value) => setOutput(value), 600))

  const updateUserValue = useCallback(({ target: { value }}) => {
    setUserValue(value);
    debounced.current(value)
  }, []);

  return (
    <div className="App">
      <div>
        <h1>Input</h1>
        <input
          value={userValue}
          type="number"
          onChange={updateUserValue}
        ></input>
      </div>
      <div>
        <h1>Output</h1>
        {output}
      </div>
    </div>
  );
}

render(<App />, document.body);
<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>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"></script>
Hysterical answered 4/12, 2019 at 8:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.