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

前端 未结 1 1027
我在风中等你
我在风中等你 2021-01-22 18:16

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.

相关标签:
1条回答
  • 2021-01-22 18:56

    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.

    0 讨论(0)
提交回复
热议问题