React | debounce with Higher Order Component

Simar Paul Singh
Simar's blog
Published in
4 min readNov 21, 2018

I was going to implement a well known solution of debouncing onChange(input: string) to feed into a search as you type feature. I took material-ui’s SearchBar as my input component which gives me two call-back props, onChange(input: string) and onRequestSearch(input: string). The former gets called on every key press in the SearchBar’s input.

Unfortunately, SearchBar from material-ui, doesn’t provide any in-built cross-cutting features like debounce, distinct, etc. I needed to debounce firing of onChange(...) by 400 ms, but didn’t want to add a middleware likeredux-sagas or redux-observables just this one use case. Also, debouncing input is a concern of presentation / interactive layer, not store / data layer. Lodash was already in my bundle, and its debounce(…) function is all what I needed.

I decided to implement a Higher Order Component, knowing that onChange(…) is a common PropType.isFunc, many presentational and native components implement for the same reason. For example, native <input onChange={this.props.changed}/> in JSX, if we used instead of material-ui’s search-box, it would need the same logic for debouncing.

import SearchBar from "material-ui-search-bar";
import
withDebounce from "./withDebounce"; // we are going to write
export const SearchBarDebounced = withDebounce(SearchBar, 400);

First implementation (Caution; this doesn’t work, explained just after the snippet)

import React from 'react';
import debounce from 'lodash/debounce'

export default (WrappedComponent, debounceMillis = 400) => {

return (props) => {
render() {
const {onChange, ...passThroughProps} = props;
const onChangeDebounced = debounce(debounceMillis);
return (
<WrappedComponent {...passThroughProps} onChange={onChangeDebounced} />
);
}
}
};

This doesn’t work at all because on every call to render(…) new props are received, we will end up creating a new wrapping of onChange(…) with debounce(…) effectively loosing the state of the one that was at work from the previous render(…). If you are using a controlled input, new props will be triggered on every key-press. (unless you are using uncontrolled inputs. Read more at https://reactjs.org/docs/uncontrolled-components.html)

In React, your inner component’s render(…) could be called many times, effectively whenever the component itself or any of its parent component has change triggering a render cycle.

We understand React runs render(…) to to determine new state of virtual DOM, and if it the resulting virtual DOM is same as last, there will be no updates on the DOM, but any stateful code in the render(…) would execute in each such render cycle, and hence a new debounce(…) wrapping onChange(…) every time even if onChange(…) hasn’t change between previous-props to next-props.

Second implementation (This works, but we could do better)

To solve the problem from the first implementation we need to determine that onChange(…) prop received has not changed from the previous render cycle (which most likely is the case) and avoid wrapping the same onChange(…) prop with new debounce(…) in every call to render(…). For this we will need state inside this higher order component.

In react-16, we have have nice static life cycle method getDerivesStateFromProps(nextProps, prevState), which allows us to sync state with props. This gives us what we need (remember onChange prop between consecutive render cycles). You can read more at https://reactjs.org/docs/react-component.html#static-getderivedstatefromprops

Let us introduce some state in this higher-order components, so it can determine if onChange(…) has changed from previous-props to next-props. It is likely that our outer component is going to pass the same onChange(…) prop between render-cycles. In the implementation below, we will retain same instance of debounce(onChange,…) across calls to render(…) if the onChange(…) prop received hasn’t changed between the render calls. However, if we do receive a new onChange(…) prop we wrap it with debounce(…) and forward afresh.

import React from 'react';
import debounce from 'lodash/debounce'

export default (WrappedComponent, debounceMillis = 400) => {

return class extends React.PureComponent {

state = {};

static getDerivedStateFromProps(nextProps, prevState) {
const nextState = {};
if (nextProps.onChange !== prevState.onChange) {
nextState.onChange = nextProps.onChange;
nextState.onChangeDebounced = debounce(nextState.onChange, debounceMillis);
}
return nextState;
};

render() {
const {forwardedRef, ...passThroughProps} = this.props;
const {onChangeDebounced} = this.state;
return (
<WrappedComponent {...passThroughProps} onChange={onChangeDebounced} />
);
}
}

};

Third and final implementation

So what we already have works, but there is a common problem with Higher Order Components in general.
What if your outer components is using ref property on the component you are wrapping to work with it’s methods directly?

As of react-16 you can use forwardRef to ensure not only props pass-through from wrapping component, but also any direct invocations made on the component, de-referencing through ref’s

import React from 'react';
import debounce from 'lodash/debounce'

export default (WrappedComponent, debounceMillis = 400) => {

class WithDebounce extends React.PureComponent {

state = {};

static getDerivedStateFromProps(nextProps, prevState) {
const nextState = {};
if (nextProps.onChange !== prevState.onChange) {
nextState.onChange = nextProps.onChange;
nextState.onChangeDebounced = debounce(nextState.onChange, debounceMillis);
}
return nextState;
};

render() {
const {forwardedRef, ...passThroughProps} = this.props;
const {onChangeDebounced} = this.state;
return (
<WrappedComponent {...passThroughProps} ref={forwardedRef} onChange={onChangeDebounced} />
);
}
}

return React.forwardRef((props, ref) => {
return <WithDebounce {...props} forwardedRef={ref}/>
});

};

If you want to understand more about the problem and the solution using forwardRef’s at https://reactjs.org/docs/forwarding-refs.html

--

--