ThinkGeek
ThinkGeek

Reputation: 5147

React - controlling async calls smartly without any side effect in complex applications

Solution proposed by codeslayer1 in question raised at React - Controlling multiple Ajax Calls has an issue of accessing state directly inside action creator - an anti pattern.

So, if I don't access the state inside my action creator what I will do is, I will listen to a batchRequestCompleted state in my component. When components prop batchRequestCompleted will become true(means previous request is completed), I will check if any pending requests are there. If yes, I will dispatch action to process those next requests. So basically saga calls action which in turn modifies the state. And once state is modified, another action to process further requests is dispatched from component. In this way, saga never accesses the state.

Solution above sounds good but comes at a cost of problem mentioned in Route change before action creator completes. That is, what will happen to the requests placed inside queue if someone navigates to a different route, before queue is cleared.

Can I solve the problem mentioned in React - Controlling multiple Ajax Calls without accessing state inside action creators and without bringing component back in picture for dispatching an action to clear the pending queue.

Note: I have created a new question because problem mentioned in React - Controlling multiple Ajax Calls is solved but with side effects and this question majorly focuses on reaching to a solution which cleans off that side effect.

Upvotes: 2

Views: 225

Answers (1)

adz5A
adz5A

Reputation: 2032

I made a little repo github.com/adz5a/so-stream-example to illustrate how I would solve your problem.

This repo uses two libraries xstream and recompose. The former provides an implementation of ObservableStreams with its operators and the latter wires it up with React.

A concept is necessary before everything : ES Observables. They are covered in depth in articles such as this (I strongly recommend reading and listening to past articles / talks from Ben Lesh, on this subject).

Observabes are a lazy primitive used to model values over time. In JS we have another primitive for doing async : Promises. Those models an eventual value or error and thus are not lazy but eager. In the case of a React component ( or more generally UI ) we are interested in lazyness because things can go wrong : the user may want to interrupt a long running process, it can crash, change route etc...

So, how can we solve your problem : controlling a long running process which can be interrupted ( fetching lots of rows ) by user interaction ?

First, the UI :

export class AnswerView extends React.Component {
    static propTypes = {
        // called when the user make a batch 
        // of request
        onStart: PropTypes.func.isRequired,
        // called when you want to stop the processing
        // of requests ( when unmounting or at the request
        // of the user )
        onStop: PropTypes.func.isRequired,
        // number of requests completed, 0 by default
        completedRequests: PropTypes.number.isRequired,
        // whether it's working right now or not
        processing: PropTypes.bool.isRequired
    };
    render () {

        // displays a form if no work is being done,
        // else the number of completed requests
        return (
            <section>
                <Link to="/other">Change Route !</Link>
                <header>
                    Lazy Component Example
                </header>
                {
                    this.props.processing ?
                        <span>{"requests done " + this.props.completedRequests}<button onClick={this.props.onStop}>Stop !</button></span>:
                        <form onSubmit={e => {
                                e.preventDefault();
                                this.props.onStart(parseInt(e.currentTarget.elements.number.value, 10));
                            }}>
                            Nb of posts to fetch<input type="number" name="number" placeholder="0"/>
                            <input type="submit" value="go"/>
                        </form>
                }
            </section>
        );

    }
    componentWillMount () {
        console.log("mounting");
    }
}

Pretty simple : a form with an input for the number of requests to perform (could checkboxes on a table component ... ).

Its props are as follow :

  • onStart : fn which takes the desired number
  • onStop : fn which takes no args and signals we would like to stop. Can be hooked to a button or in this case, componentWillUnmout.
  • completedRequests: Integer, counts requests done, 0.
  • processing: boolean, indicates if work is under way.

This does not do much by itself, so let's introduce recompose. Its purpose is to enhance component via HOC. We will use the mapPropsStream helper in this example.

Note : in this answer I use stream / Observable interchangeably but this is not true in the general case. A stream is an Observable with operators allowing to transform the emitted value into a new Observable.

For a React Component we can sort of observe its props with the standard api : 1st one at componentWillMount, then at componentWillReceiveProps. We can also signal when there will be no more props with componentWillUnmount. We can build the following (marble) diagram : p1--p2--..--pn--| (the pipe indicates the completion of the stream).

The enhancer code is posted below with comments.

What needs to be understood is that everything with streams can be approached like a signal : by modelling everything as a stream we can be sure that by sending the appropriate signal we can have the desired behaviour.

export const enhance = mapPropsStream(prop$ => {

    /*
     * createEventHandler will help us generates the callbacks and their
     * corresponding streams. 
     * Each callback invocation will dispatch a value to their corresponding
     * stream.
     */

    // models the requested number of requests
    const { handler: onStart, stream: requestCount$ } = createEventHandler();
    // models the *stop* signals
    const { handler: onStop, stream: stop$ } = createEventHandler();

    // models the number of completed requests
    const completedRequestCount$ = requestCount$.map( n => {

        // for each request, generate a dummy url list
        const urls = Array.from({ length: n }, (_, i) => `https://jsonplaceholder.typicode.com/posts/${i + 1}` );

        // this is the trick : we want the process to be aware of itself when
        // doing the next operation. This is a circular invocation so we need to
        // use a *proxy*. Note : another way is to use a *subject* but they are
        // not present in __xstream__, plz look at RxJS for a *subject* overview
        // and implementation.
        const requestProxy$ = xs.create();

        const count$ = requestProxy$
        // a *reduce* operation to follow where we are
        // it acts like a cursor.
            .fold(( n ) => n + 5, 0 )
        // this will log the current value
            .debug("nb");

        const request$ = count$.map( n => Promise.all(urls.slice(n, n + 5).map(u => fetch(u))) )
            .map(xs.fromPromise)
            .flatten()
            .endWhen(xs.merge(
        // this stream completes when the stop$ emits
        // it also completes when the count is above the urls array length
        // and when the prop$ has emitted its last value ( when unmounting )
                stop$,
                count$.filter(n => n >= urls.length),
                prop$.last()
            ));



        // this effectively activates the proxy
        requestProxy$.imitate(request$);

        return count$;

    } )
        .flatten();

    // models the processing props,
    // will emit 2 values : false immediately,
    // true when the process starts.
    const processing$ = requestCount$.take(1)
        .mapTo(true)
        .startWith(false);

    // combines each streams to generate the props
    return xs.combine(
        // original props
        prop$,
        // completed requests, 0 at start
        completedRequestCount$.startWith(0),
        // boolean indicating if processing is en route
        processing$
    )
        .map(([ props, completedRequests, processing ]) => {

            return {
                ...props,
                completedRequests,
                processing,
                onStart,
                onStop
            };

        })
    // allows us to catch any error generated in the streams
    // very much equivalent to the new ErrorBoundaries in React
        .replaceError( e => {
            // logs and return an empty stream which will never emit,
            // effectively blocking the component
            console.error(e);
            return xs.empty();
        } );

});

export const Answer = enhance(AnswerView);

I hope this answer is not (too) convoluted, feel free to ask any question.

As a side note, after a little research you may notice that the processing boolean is not really used in the logic but is merely there to help the UI know what's going on : this is a lot cleaner than having some piece of state attached to the this of a Component.

Upvotes: 1

Related Questions