Skip to content
This repository has been archived by the owner on Apr 13, 2023. It is now read-only.

Suggest feature: Debounce option in query operation. #450

Closed
zxlwbi199101 opened this issue Feb 3, 2017 · 22 comments
Closed

Suggest feature: Debounce option in query operation. #450

zxlwbi199101 opened this issue Feb 3, 2017 · 22 comments
Labels
feature New addition or enhancement to existing solutions

Comments

@zxlwbi199101
Copy link

zxlwbi199101 commented Feb 3, 2017

usage scenario

I use redux, and wrap MyListComponent by graphql(queryStr, { options }), when some options changes, it fetch from the server immediately. Have a look at 2 use scenarios:

  • there's a search input, display the result based on the search value dynamically, when use is typing, graphWrapper fetches rapidly.
  • the options are stored in the url, like list?filter=name&sort=age&..., the params are parsed in componentWillmount then dispatched to redux store. So when first loaded, the graphWrapper made a fetch with init options, then made another fetch with the dispatched values. So the first one is a waste.

I understand for both scenario is not a apollo specific problem, and can be fixed easily through _.debounce or wrap another component (maybe a little bit harder to debounce when binding the redux store value to that input). But I think the code will be more clean if apollo support a debounce option.

what I have tried

  • I wrote a apollo-client middleware, to make debounce at the network level.
import _ from 'lodash';

const operationMap = {
  // operationName: {timer}
};

export default wait => ({ request, options }, next) => {
  // skip all the mutations
  if (_.get(request, 'query.definitions[0].operation') === 'mutation') {
    return next();
  }

  const operationName = request.operationName;

  let operationObject = operationMap[operationName];
  if (!operationObject) {
    operationObject = operationMap[operationName] = {};
  }

  // TODO: apollo-client uses promise to apply middleware,
  //       pending lots of promise will cause memory leak ??
  if (operationObject.timer > 0) {
    clearTimeout(operationObject.timer);
    operationObject.timer = -1;
  }

  operationObject.timer = setTimeout(() => {
    operationObject.timer = -1;
    next();
  }, wait);
};

Apparently this just debounce queries with their name, and if there are more than one queries to the same api, this middleware will cause bug. And if added a whitelist or blacklist to operationName, I don't think it's a graceful design.

thoughts

  • Is there any other mechanism can I apply such a debounce feature ?
  • Is it feasible to add another level middleware / plugin (not network level) ?
@calebmer calebmer added feature New addition or enhancement to existing solutions help wanted labels Feb 3, 2017
@ksmth ksmth mentioned this issue Mar 24, 2017
6 tasks
@helfer
Copy link
Contributor

helfer commented Mar 28, 2017

Hey @zxlwbi199101 and @ksmth. Thanks for the issue and PR! Here are my thoughts:

Even if it would be a little bit nicer to use if it's integrated with react-apollo, it's something that I think should clearly be kept outside because it can be done by simply wrapping the component.

We have to carefully consider every addition to Apollo, because it isn't as simple as just clicking 'merge'. When we merge something, we have to make sure that it keeps working, even as we make more changes to Apollo Client. The more features we have, the harder it becomes to maintain the core functionality and develop it further, so whenever something can be reasonably done outside of Apollo, that feature should not be added in my opinion.

@ksmth
Copy link
Contributor

ksmth commented Mar 28, 2017

@helfer please explain how it can be done by wrapping the component as I have trouble understanding how I can both debounce a value change down the hierarchy while at the same time passing the value for the component to consume.

@helfer
Copy link
Contributor

helfer commented Mar 28, 2017

@ksmth I'm no React expert, but I think something like this should work:

You keep a state variable in the wrapper component to decide whether or not to re-render the child. When you render the child you set a timeout. Until that timeout elapses you don't re-render the child, even if the props are updated. When the timeout elapses you re-render the child component. Does that make sense?

Another maybe more suitable approach would be to debounce the event handler at the source so your props don't change that frequently.

@ksmth
Copy link
Contributor

ksmth commented Mar 28, 2017

It's not as straight forward as it first seems. Consider this example:

const withState = connect(
  state => ({ value: state.value }),
  dispatch => ({ onChange: value => dispatch({ type: 'my-app/search/CHANGE_VALUE', value }) }),
);
const withData = graphql(DATA_QUERY, {
  options: props => ({
    variables: { name: props.value },
  }),
});
const MyComponent = ({ value, onChange }) => (
  <input value={value} onChange={({ target : { value } }) => onChange(value)} />
);
export default compose(
  withState,
  withData,
)(MyComponent);

value here fulfils multiple purposes:

  • it's the input for your graphql variables
  • it's the value for your controlled <input />

These two requirements are in conflict, as you want the most up to date data for your <input />, but you want less rapidly changing data as the input for your query variables.

Now if you delay either the rendering or the dispatch of the onChange, it won't update the <input /> with the new value for the duration of the timeout, effectively breaking the input, as it wouldn't show the new value and the user is not able to type any further. With both approaches, you have to split your value in two, a value and a valueDebounced. Doing this at the level of the component that is triggering that callback would result in an API change, you'd have onValueChange and onValueDebouncedChange. So of the two approaches your initial idea I'd prefer using a wrapper:

const withDebouncedProps = ({ debounce = 0, propNames = [] }) => WrappedComponent => class WithDebouncedProps extends Component {
  static displayName = `withDebouncedProps(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`

  constructor(props) {
    super(props);
    this.state = this.generateNextState(props, { debouncing: false });
  }

  generateNextState(props, state) {
    return propNames.reduce((nextState, propName) => ({
      ...nextState,
      [ `${propName}Debounced` ] : props[ propName ],
    }), state);
  }

  componentWillReceiveProps(nextProps, prevProps) {
    if (shallowEqual(nextProps, prevProps)) {
      return;
    }

    clearTimeout(this.timeout);

    if (!this.state.debouncing) {
      this.setState({ debouncing : true });
    }

    this.timeout = setTimeout(() => this.setState({
      ...this.generateNextState(nextProps, this.state),
      debouncing: false,
    }), debounce);
  }

  render() {
    return <WrappedComponent {...this.props} {...this.state} />;
  }
}

You'd then get something like:

const withState = connect(
  state => ({ value: state.value }),
  dispatch => ({ onChange: value => dispatch({ type: 'my-app/search/CHANGE_VALUE', value }) }),
);
const withData = graphql(DATA_QUERY, {
  options: props => ({
    variables: { name: props.valueDebounced },
  }),
});
export default compose(
  withState,
  withDebouncedProps({ debounce: 100, propNames: [ 'value' ] })
  withData,
)(MyComponent);

As I imagine this to be a really common use case for Apollo though, I'd still argue for it to be a feature in react-apollo. I could argue for it to be a feature of apollo-client, as all the different bindings will have a similar need for a debounced query that doesn't trash a server. Any app with a search feature of some kind would need to implement a wrapper like I have outlined above. At the same time I understand the goal to maintain a minimal feature set to keep things light in terms of maintenance effort.

In order to make this a useful addition to the docs, I'd like to know where to put it. Would it be another menu item in the Recipes section?

@helfer
Copy link
Contributor

helfer commented Apr 7, 2017

@ksmth I agree that you want your input field to update immediately, but your query shouldn't run immediately, but why not just make the list / result component be either a sibling or a child to the input component? That way the input can rerender independently from the list, which will re-render when the query is fetched again.

Debouncing is certainly a useful feature, but if you ask me we already have features in React Apollo that don't really need to be there. The same is true for Apollo Client core, which currently has some features that should live either in React Apollo or in an integration layer. Having these features isn't bad per se, but unfortunately every feature that gets added makes the library harder to maintain because it adds code that could interact with other features and cause the client to break in unexpected ways. The larger the library, the harder it will be and the longer it will take to get each little bug fixed, so I think it's not just a benefit for maintainers to keep things modular, but also a great benefit for anyone who is using the library, because it means they don't need to wait for the library to be updated to fix bugs.

@jacobk
Copy link
Contributor

jacobk commented Apr 7, 2017

When using Redux (or any other flux pattern) together with controlled inputs – siblings or not – you effectively need to create virtual debounced prop only for the purpose of passing to apollo. As far as making the typical redux app's state management clean and not concerned with data fetching concerns (which, maybe a bit contrived, can be compared with timeout handling, retries etc), support in the client would be great.

If the client is to be kept simple which I'm all in favor of, the available options are to either say that the debounced values should be part of the global state because, maybe, some other part of the app should render differently depending on the debounced value. Or wrap the graphql-components with hidden state using patterns like @ksmth outlined above.

IMHO, it feels a little awkward to add such a HoC for the purpose of injecting the debounced prop, read it during graphql config and then strip it away when mapping props to the underlying component. It also adds complexity in making sure to know the name and use the debounced props in the query options.

I have a similar version of the HoC above but instead of passing a list of prop names I do:

export function withDebouncedProps({ debounce, props }: {
  debounce: number,
  props: (props: Object) => Object,
}) {
  ...
}

which allows me to control the names of the debounced props and somewhat easier type annotations.

Maybe targeting the wrapper directly for the grapqhl HoC to extend it rather than the component with debounced props feels better? i.e. debouncedGraphql(...) that internally wraps graphql()

@ksmth
Copy link
Contributor

ksmth commented Apr 7, 2017

Making the List / Result component a child or sibling to the input component on its own wouldn't solve the problem, you'd still have to debounce the value changes in some way. It doesn't matter where the results component lives, as long as it depends on a state that is frequently changing, such as the value of an input component, or any other input for that matter, it needs to be debounced as you'd be potentially sending too many requests.

As I said previously, I completely understand and agree with the importance of keeping the library as small as possible. We only disagree on whether debouncing is a common use case or not. It seems to me, running queries based on changing values is particularly common, but I'm also aware that my own needs might conflate my perception of the importance of this feature.

What I think we both can agree on though is, that giving users of react-apollo some help here is the way to go and I'd be willing to work on a section for the docs, you'd just need to let me know where to put it. I've also pinged @stubailo on Slack regarding this topic and he hinted at a blogpost that might make sense.

@jacobk
Copy link
Contributor

jacobk commented Apr 7, 2017

Adding to my idea above, to create a wrapped graphql (debouncedGraphql) it should be possible to configure the query using the original propnames and only do the mapping to explicitly debounced props inside the wrapper.

I'll see if I can find some time to play around with that concept.

@xiankai
Copy link

xiankai commented Apr 25, 2017

I have encountered a similar use-case for debouncing, in addition to the search mentioned here as well. In my case, I have a username input that also does on-the-fly validation to check if the username has already been taken or not.

I also think that debouncing can be a very general use-case for anything to deal with the network - especially to reduce the number of requests. In apollo-client there is also a batchedNetworkLayer that works on the same principle.

However for a client-side prop that does not need such an over-engineered solution, using a wrapped graphql and then passing the original props to prevent triggering a request during the debounce period sounds like a great idea.

@jbaxleyiii
Copy link
Contributor

This would be a great recipe and/or an external package. I don't think it should be in the core RA though!

@mjadczak
Copy link

mjadczak commented Jul 1, 2017

FWIW, here is a quick solution I came up with using recompose. I only needed to debounce a single prop, but this could be extended to more I'm sure.

import _debounce from 'lodash/debounce'
import _omit from 'lodash/fp/omit'
import { compose, withState, withHandlers, mapProps, withProps } from 'recompose'

const omitProps = compose(mapProps, _omit)

const debounceProp = (propToDebounce, delay = 150, maxWait = 500) => {
  const propAccessor = `_debouncedProp_${propToDebounce}`
  const propSetter = `_setDebouncedProp_${propToDebounce}`
  const propDebouncer = `_setPropDebounced_${propToDebounce}`

  const debounceState = withState(propAccessor, propSetter, props => props[propToDebounce])

  const debounceHandler = withHandlers({
    [propDebouncer]: props => _debounce(props[propSetter], delay, { maxWait })
  })
  const finalPropMap = withProps(props => {
    if (props[propToDebounce] !== props[propAccessor]) {
      props[propDebouncer](props[propToDebounce])
    }
    return {
      [propToDebounce]: props[propAccessor]
    }
  })

  return compose(
    debounceState,
    debounceHandler,
    finalPropMap,
    omitProps([propAccessor, propSetter, propDebouncer])
  )
}

export default debounceProp

And I'm using it like this (my use case is a redux-controlled search input driving the searchQuery variable in an Apollo query):

const enhance = compose(
  connect(mapStateToProps),
  debounceProp('searchQuery', 500, 1500),
  withFeed,
  branch(({ data }) => data.loading, renderComponent(LoadingView)),
  branch(({ data }) => data.error, renderComponent(ErrorView)),
)

@tkvw
Copy link

tkvw commented Sep 22, 2017

@ksmth : thanks for your solution, works great! (why didn't you use PureComponent instead of shallowEqual?)

I agree with @ksmth that debouncing queries is something that should be supported and I agree with @helfer not to overcomplicate the library, so that makes me a bit crazy.

A simple solution could be to allow the options function to return a Promise and only query if the promise is resolved? This would allow us to do whatever we want.

@tkvw
Copy link

tkvw commented Oct 20, 2017

Just for reference, I created a debouncing hoc for input components. It switches to localstate value while debouncing and switches back afterwards, so this can be used with redux.
Usage as component:

<OnChangeDebounce as={Input} wait={500} onDebounce={(value)=>updateSearch(value)}/>

Usage as hoc:

onChangeDebounce({
    wait:500,
    // Semantic UI uses a different value resolution for 'divable' input components 
    getValueFromEvent: (e,data) => data.value
})(Input)

Component:

import React from 'react';
import PropTypes from 'prop-types';
import debounce from 'lodash/debounce';
class OnChangeDebounce extends React.Component{
    constructor(props){
        super(props);
        
        this.isValueSourceProps = this.props.hasOwnProperty(this.props.valueProperty);
        this._createDebounceFunction(props);
        
        this.state = {
            value:this.props[this.props.valueProperty]||''
        }        
    }
    componentWillReceiveProps = (nextProps) => {
        this.isValueSourceProps = nextProps.hasOwnProperty(nextProps.valueProperty);
        
        // Check if the debounce function should be recreated 
        if(nextProps.wait !== this.props.wait || nextProps.waitOptions !== this.props.waitOptions){
            this._createDebounceFunction(nextProps);
        }
        // Check if the displaying value is updated and the input isn't debouncing
        // Redux will typically supply a value which is not inline with the current
        // input value, but if not debouncing the value should be updated
        if(nextProps.hasOwnProperty(this.props.valueProperty) && !this.state.debouncing){
            this.setState({
                value: nextProps[this.props.valueProperty]
            });
        }
    };
    _createDebounceFunction = (props) => {
        this.onChangeDebounce = debounce(
            (...args)=> {
                this.setState({
                    debouncing:false
                });
                // Execute in promise so the ui thread can return
                Promise.resolve().then(()=> this.props.onDebounce(this.props.getValueFromEvent(...args)))
            }
        ,props.wait,props.waitOptions);
    };
    
    onChange = (...args) => {
        this.setState({
           value:this.props.getValueFromEvent(...args),
           debouncing:true 
        });        
        this.onChangeDebounce(...args);        
    };
    
    render(){
        const {
            as:As,
            valueProperty,
            onChangeProperty,
            onDebounce,
            getValueFromEvent,
            wait,
            waitOptions,
            ...props
        } = this.props;
        
        const value = !this.state.debouncing && this.isValueSourceProps ?
            this.props[valueProperty]: 
            this.state.value;
        
        return <As
            {...props}
            {...{
                [valueProperty]: value,
                [onChangeProperty]: this.onChange
            }}
        />
    }
};

OnChangeDebounce.propTypes={
    onDebounce: PropTypes.func.isRequired,
    valueProperty: PropTypes.string,
    onChangeProperty: PropTypes.string,
    getValueFromEvent: PropTypes.func,
    wait: PropTypes.number,
    waitOptions: PropTypes.shape({
        leading: PropTypes.bool,
        maxWait: PropTypes.number,
        trailing: PropTypes.bool
    })
};
OnChangeDebounce.defaultProps={
    valueProperty:'value',
    onChangeProperty:'onChange',
    wait: 300,
    waitOptions:null,
    getValueFromEvent:(e)=> e&& e.target && e.target.value
};

export default debounceProps => As => props => 
    <OnChangeDebounce as={As} {...props} {...debounceProps}/>;

@sfarthin
Copy link

Just wanted to throw out my solution that is considerably less code. Any downside to this approach over debounceProp?

import { debounce } from 'lodash'

// Utilizing closures to debounce functions from props.
// If debounce is applied within the component, the "debounced function"
// will change each time. By defining it statically here, react will always
// consider this to be the same function.
const debounceCall = debounce(func => func(), 250);


const SearchComponent = ({
  // These methods/state can be managed by redux
  // or something else by a parent component
  inputValue,
  updateInputValue,
  searchValue,
  updateSearchValue,
  data // <-- data from apollo
}) => {
  return (
    <div>
      <div>Searching for {searchValue}</div>
      <input
        type="text"
        value={inputValue}
        onChange={e => {
          updateInputValue(e.target.value);
          debounceCall(() => {
            updateSearchValue(e.target.value);
          });
        }}
      />
    </div>
  );
};

const withData = graphql(SEARCH_QUERY, {
  options: props => ({
    variables: { search: props.searchValue },
  }),
});

export default withData(SearchComponent);

@saltycrane
Copy link

Yet another solution: https://github.com/saltycrane/react-debounced-props

Higher order component:

const withDebouncedProps = (propNames, debounce) => BaseComponent =>
  class ComponentWithDebouncedProps extends React.Component {
    static displayName = `withDebouncedProps(${BaseComponent.displayName ||
      BaseComponent.name})`;

    constructor(props) {
      super(props);
      this.state = pick(props, propNames);
    }

    componentWillReceiveProps(nextProps) {
      this.debouncedUpdate(nextProps);
    }

    componentWillUnmount() {
      this.debouncedUpdate.cancel();
    }

    debouncedUpdate = debounce(nextProps => {
      this.setState(pick(nextProps, propNames));
    });

    render() {
      return <BaseComponent {...omit(this.props, propNames)} {...this.state} />;
    }
  };

Example usage:

import { debounce } from "lodash";
import { compose, graphql } from "react-apollo";

const MyEnhancedComp = compose(
  withDebouncedProps(["regionName"], func => debounce(func, 200)),
  graphql(REGIONS_QUERY),
)(MyComponent);

@leebenson
Copy link

leebenson commented May 23, 2018

Edit: Scratch my last message; the solution @saltycrane posted works perfectly.

I've added a Typescript version here

@dardub
Copy link

dardub commented Nov 22, 2018

Seeing that various people are coming up with different and complex solutions makes a stronger argument that this should be provided by apollo.

@astorije
Copy link

Sorry to be late to the party but another argument in favor of adding debouncing support as part of Apollo core as opposed to on handler event is that such debouncing could be cache-aware and not apply to already cached values.

For example, using the use case of instant search, if I type "foo", it makes sense to debounce, say after 500ms, to only send a query with foo. However, if I empty the field and type "foo" again, I shouldn't have to wait 500ms for the results to be updated, that could be filtered instantly.

I understand both sides of the argument, but I think having a debounce={500} argument would be a really neat addition with strong use cases.

@capaj
Copy link

capaj commented Jul 22, 2019

I think this is such a common need in any react app, that it should be a part of react-apollo.

@EloB
Copy link

EloB commented Jan 22, 2020

How do you debounce with the hooks?

@RMHonor
Copy link

RMHonor commented Jun 23, 2020

@EloB Untested, but something along these lines would do the trick:

const useData = (input: string) => {
  const [debounceValue, setDebouncedValue] = useState(input);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(input);
    }, 500);

    return () => {
      clearTimeout(handler);
    };
  }, [input]);

  const response = useQuery(QUERY, {
    variables: {
      input: debounceValue,
    },
  });

  return response;
};

@EloB
Copy link

EloB commented Jun 23, 2020

@EloB Untested, but something along these lines would do the trick:

const useData = (input: string) => {
  const [debounceValue, setDebouncedValue] = useState(input);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(input);
    }, 500);

    return () => {
      clearTimeout(handler);
    };
  }, [input]);

  const response = useQuery(QUERY, {
    variables: {
      input: debounceValue,
    },
  });

  return response;
};

I’m thankful for your deed but my thought was more about I need 10-11 extra lines of code for something that is such common need.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
feature New addition or enhancement to existing solutions
Projects
None yet
Development

No branches or pull requests