Skip to content
Advertisement

Debouncing / throttling a callback in React using hooks without waiting for the user to stop typing to get the update

I’m using React 16.8.6 with Hooks and Formik 1.5.7 to build a form with a preview of the content that will be generated with that data later on. The form runs fine on its own, but as long as I render the preview as well, everything becomes a bit slow and sluggish.

I’ve fixed that debouncing the onChange of the form using setTimeout, but I’d like it to be called periodically even if the user keeps typing:

const Preview = ({ values: { name = '', message = '' } }) => (<div className="preview">
  <p><strong>{ name }</strong></p>
  { message.split(/r?nr?n/g).filter(Boolean).map((text, i) => (<p key={ i }>{ text }</p>)) }
</div>);

const Form = ({ onChange }) => {
  const [values, setValues] = React.useState({});
  
  // Let's assume this is internal code from Formik...:
  
  const handleChange = React.useCallback(({ target }) => {
    setValues(values => {
      const nextValues = ({ ...values, [target.name]: target.value });
      
      onChange(nextValues);
    
      return nextValues;
    });
  }, []);
  
  return (<form>
    <input type="text" value={ values.name || '' } name="name" onChange={ handleChange } />
    <textarea value={ values.message || '' } name="message" onChange={ handleChange } />
  </form>);
};

const App = () => {
  const [formValues, setFormValues] = React.useState({});
  const timeoutRef = React.useRef();
  
  React.useEffect(() => window.clearTimeout(timeoutRef.current), []);
  
  const handleFormChange = React.useCallback((values) => {
    window.clearTimeout(timeoutRef.current);
    
    timeoutRef.current = window.setTimeout(() => setFormValues(values), 500);
  }, []);
  
  return (<div className="editor">
    <Form onChange={ handleFormChange } />
    <Preview values={ formValues } />
  </div>);
};

ReactDOM.render(<App />, document.querySelector('#app'));
body {
  margin: 0;
}

body,
input,
textarea {
  font-family: monospace;
}

.editor {
  display: flex;
}

form,
.preview {
  position: relative;
  max-width: 480px;
  width: 100%;
  margin: 0 auto;
  padding: 8px;
  
}

input,
textarea {
  border: 2px solid black;
  border-radius: 2px;
  display: flex;
  padding: 8px;
  margin: 0 auto 8px;
  width: 100%;
  box-sizing: border-box;
}

.preview {
  border-left: 2px solid black;
}
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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

Answer

You could define a custom withThrottledCallback hook that takes care of that and replaces/combines these lines:

const timeoutRef = React.useRef();

React.useEffect(() => window.clearTimeout(timeoutRef.current), []);

const handleFormChange = React.useCallback((values) => {
    window.clearTimeout(timeoutRef.current);

    timeoutRef.current = window.setTimeout(() => setFormValues(values), 500);
}, []);

Into something like this:

const throttledHandleFormChange = useThrottledCallback((values) => {
    setFormValues(values);
}, 500, []);

And that fires regularly every 500 ms even if the user keeps typing.

This way, rather than using setTimeout directly, you would be able to reuse this functionality declaratively, just like Dan Abramov suggests for setInterval in Making setInterval Declarative with React Hooks.

It will look something like this:

function useThrottledCallback(callback, delay, deps) {
  const timeoutRef = React.useRef();
  const callbackRef = React.useRef(callback);
  const lastCalledRef = React.useRef(0);

  // Remember the latest callback:
  //
  // Without this, if you change the callback, when setTimeout kicks in, it
  // will still call your old callback.
  //
  // If you add `callback` to useCallback's deps, it will also update, but it
  // might be called twice if the timeout had already been set.

  React.useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // Clear timeout if the components is unmounted or the delay changes:
  React.useEffect(() => window.clearTimeout(timeoutRef.current), [delay]);

  return React.useCallback((...args) => {
    // Clear previous timer:
    window.clearTimeout(timeoutRef.current);

    function invoke() {
      callbackRef.current(...args);
      lastCalledRef.current = Date.now();
    }

    // Calculate elapsed time:
    const elapsed = Date.now() - lastCalledRef.current;

    if (elapsed >= delay) {
      // If already waited enough, call callback:
      invoke();
    } else {
      // Otherwise, we need to wait a bit more:
      timeoutRef.current = window.setTimeout(invoke, delay - elapsed);
    }
  }, deps);
}

const Preview = ({ values: { name = '', message = '' } }) => (<div className="preview">
  <p><strong>{ name }</strong></p>
  { message.split(/r?nr?n/g).filter(Boolean).map((text, i) => (<p key={ i }>{ text }</p>)) }
</div>);

const Form = ({ onChange }) => {
  const [values, setValues] = React.useState({});
  
  // Let's assume this is internal code from Formik...:
  
  const handleChange = React.useCallback(({ target }) => {
    setValues(values => {
      const nextValues = ({ ...values, [target.name]: target.value });
      
      onChange(nextValues);
    
      return nextValues;
    });
  }, []);
  
  return (<form>
    <input type="text" value={ values.name || '' } name="name" onChange={ handleChange } />
    <textarea value={ values.message || '' } name="message" onChange={ handleChange } />
  </form>);
};

const App = () => {
  const [formValues, setFormValues] = React.useState({});
    
  const throttledHandleFormChange = useThrottledCallback((values) => {
    setFormValues(values);
  }, 500, []);
  
  return (<div className="editor">
    <Form onChange={ throttledHandleFormChange } />
    <Preview values={ formValues } />
  </div>);
};

ReactDOM.render(<App />, document.querySelector('#app'));
body {
  margin: 0;
}

body,
input,
textarea {
  font-family: monospace;
}

.editor {
  display: flex;
}

form,
.preview {
  position: relative;
  max-width: 480px;
  width: 100%;
  margin: 0 auto;
  padding: 8px;
  
}

input,
textarea {
  border: 2px solid black;
  border-radius: 2px;
  display: flex;
  padding: 8px;
  margin: 0 auto 8px;
  width: 100%;
  box-sizing: border-box;
}

textarea {
  resize: vertical;
}

.preview {
  border-left: 2px solid black;
}
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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

You can also find declarative version of setTimeout and setInterval, useTimeout and useInterval, plus a custom useThrottledCallback hook written in TypeScript in https://www.npmjs.com/package/@swyg/corre.

Advertisement