How to dispatch a Redux action with a timeout?

后端 未结 12 1816
花落未央
花落未央 2020-11-22 04:14

I have an action that updates the notification state of my application. Usually, this notification will be an error or info of some sort. I need to then dispatch another act

相关标签:
12条回答
  • 2020-11-22 04:55

    After trying the various popular approaches (action creators, thunks, sagas, epics, effects, custom middleware), I still felt that maybe there was room for improvement so I documented my journey in this blog article, Where do I put my business logic in a React/Redux application?

    Much like the discussions here, I tried to contrast and compare the various approaches. Eventually it led me to introducing a new library redux-logic which takes inspiration from epics, sagas, custom middleware.

    It allows you to intercept actions to validate, verify, authorize, as well as providing a way to perform async IO.

    Some common functionality can simply be declared like debouncing, throttling, cancellation, and only using the response from the latest request (takeLatest). redux-logic wraps your code providing this functionality for you.

    That frees you to implement your core business logic however you like. You don't have to use observables or generators unless you want to. Use functions and callbacks, promises, async functions (async/await), etc.

    The code for doing a simple 5s notification would be something like:

    const notificationHide = createLogic({
      // the action type that will trigger this logic
      type: 'NOTIFICATION_DISPLAY',
      
      // your business logic can be applied in several
      // execution hooks: validate, transform, process
      // We are defining our code in the process hook below
      // so it runs after the action hit reducers, hide 5s later
      process({ getState, action }, dispatch) {
        setTimeout(() => {
          dispatch({ type: 'NOTIFICATION_CLEAR' });
        }, 5000);
      }
    });
        

    I have a more advanced notification example in my repo that works similar to what Sebastian Lorber described where you could limit the display to N items and rotate through any that queued up. redux-logic notification example

    I have a variety of redux-logic jsfiddle live examples as well as full examples. I'm continuing to work on docs and examples.

    I'd love to hear your feedback.

    0 讨论(0)
  • 2020-11-22 04:56

    Using Redux-saga

    As Dan Abramov said, if you want more advanced control over your async code, you might take a look at redux-saga.

    This answer is a simple example, if you want better explanations on why redux-saga can be useful for your application, check this other answer.

    The general idea is that Redux-saga offers an ES6 generators interpreter that permits you to easily write async code that looks like synchronous code (this is why you'll often find infinite while loops in Redux-saga). Somehow, Redux-saga is building its own language directly inside Javascript. Redux-saga can feel a bit difficult to learn at first, because you need basic understanding of generators, but also understand the language offered by Redux-saga.

    I'll try here to describe here the notification system I built on top of redux-saga. This example currently runs in production.

    Advanced notification system specification

    • You can request a notification to be displayed
    • You can request a notification to hide
    • A notification should not be displayed more than 4 seconds
    • Multiple notifications can be displayed at the same time
    • No more than 3 notifications can be displayed at the same time
    • If a notification is requested while there are already 3 displayed notifications, then queue/postpone it.

    Result

    Screenshot of my production app Stample.co

    Code

    Here I named the notification a toast but this is a naming detail.

    function* toastSaga() {
    
        // Some config constants
        const MaxToasts = 3;
        const ToastDisplayTime = 4000;
    
    
        // Local generator state: you can put this state in Redux store
        // if it's really important to you, in my case it's not really
        let pendingToasts = []; // A queue of toasts waiting to be displayed
        let activeToasts = []; // Toasts currently displayed
    
    
        // Trigger the display of a toast for 4 seconds
        function* displayToast(toast) {
            if ( activeToasts.length >= MaxToasts ) {
                throw new Error("can't display more than " + MaxToasts + " at the same time");
            }
            activeToasts = [...activeToasts,toast]; // Add to active toasts
            yield put(events.toastDisplayed(toast)); // Display the toast (put means dispatch)
            yield call(delay,ToastDisplayTime); // Wait 4 seconds
            yield put(events.toastHidden(toast)); // Hide the toast
            activeToasts = _.without(activeToasts,toast); // Remove from active toasts
        }
    
        // Everytime we receive a toast display request, we put that request in the queue
        function* toastRequestsWatcher() {
            while ( true ) {
                // Take means the saga will block until TOAST_DISPLAY_REQUESTED action is dispatched
                const event = yield take(Names.TOAST_DISPLAY_REQUESTED);
                const newToast = event.data.toastData;
                pendingToasts = [...pendingToasts,newToast];
            }
        }
    
    
        // We try to read the queued toasts periodically and display a toast if it's a good time to do so...
        function* toastScheduler() {
            while ( true ) {
                const canDisplayToast = activeToasts.length < MaxToasts && pendingToasts.length > 0;
                if ( canDisplayToast ) {
                    // We display the first pending toast of the queue
                    const [firstToast,...remainingToasts] = pendingToasts;
                    pendingToasts = remainingToasts;
                    // Fork means we are creating a subprocess that will handle the display of a single toast
                    yield fork(displayToast,firstToast);
                    // Add little delay so that 2 concurrent toast requests aren't display at the same time
                    yield call(delay,300);
                }
                else {
                    yield call(delay,50);
                }
            }
        }
    
        // This toast saga is a composition of 2 smaller "sub-sagas" (we could also have used fork/spawn effects here, the difference is quite subtile: it depends if you want toastSaga to block)
        yield [
            call(toastRequestsWatcher),
            call(toastScheduler)
        ]
    }
    

    And the reducer:

    const reducer = (state = [],event) => {
        switch (event.name) {
            case Names.TOAST_DISPLAYED:
                return [...state,event.data.toastData];
            case Names.TOAST_HIDDEN:
                return _.without(state,event.data.toastData);
            default:
                return state;
        }
    };
    

    Usage

    You can simply dispatch TOAST_DISPLAY_REQUESTED events. If you dispatch 4 requests, only 3 notifications will be displayed, and the 4th one will appear a bit later once the 1st notification disappears.

    Note that I don't specifically recommend dispatching TOAST_DISPLAY_REQUESTED from JSX. You'd rather add another saga that listens to your already-existing app events, and then dispatch the TOAST_DISPLAY_REQUESTED: your component that triggers the notification, does not have to be tightly coupled to the notification system.

    Conclusion

    My code is not perfect but runs in production with 0 bugs for months. Redux-saga and generators are a bit hard initially but once you understand them this kind of system is pretty easy to build.

    It's even quite easy to implement more complex rules, like:

    • when too many notifications are "queued", give less display-time for each notification so that the queue size can decrease faster.
    • detect window size changes, and change the maximum number of displayed notifications accordingly (for example, desktop=3, phone portrait = 2, phone landscape = 1)

    Honnestly, good luck implementing this kind of stuff properly with thunks.

    Note you can do exactly the same kind of thing with redux-observable which is very similar to redux-saga. It's almost the same and is a matter of taste between generators and RxJS.

    0 讨论(0)
  • 2020-11-22 05:02

    The appropriate way to do this is using Redux Thunk which is a popular middleware for Redux, as per Redux Thunk documentation:

    "Redux Thunk middleware allows you to write action creators that return a function instead of an action. The thunk can be used to delay the dispatch of an action, or to dispatch only if a certain condition is met. The inner function receives the store methods dispatch and getState as parameters".

    So basically it returns a function, and you can delay your dispatch or put it in a condition state.

    So something like this is going to do the job for you:

    import ReduxThunk from 'redux-thunk';
    
    const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
    
    function increment() {
      return {
        type: INCREMENT_COUNTER
      };
    }
    
    function incrementAsync() {
      return dispatch => {
        setTimeout(() => {
          // Yay! Can invoke sync or async actions with `dispatch`
          dispatch(increment());
        }, 5000);
      };
    }
    
    0 讨论(0)
  • Why should it be so hard? It's just UI logic. Use a dedicated action to set notification data:

    dispatch({ notificationData: { message: 'message', expire: +new Date() + 5*1000 } })
    

    and a dedicated component to display it:

    const Notifications = ({ notificationData }) => {
        if(notificationData.expire > this.state.currentTime) {
          return <div>{notificationData.message}</div>
        } else return null;
    }
    

    In this case the questions should be "how do you clean up old state?", "how to notify a component that time has changed"

    You can implement some TIMEOUT action which is dispatched on setTimeout from a component.

    Maybe it's just fine to clean it whenever a new notification is shown.

    Anyway, there should be some setTimeout somewhere, right? Why not to do it in a component

    setTimeout(() => this.setState({ currentTime: +new Date()}), 
               this.props.notificationData.expire-(+new Date()) )
    

    The motivation is that the "notification fade out" functionality is really a UI concern. So it simplifies testing for your business logic.

    It doesn't seem to make sense to test how it's implemented. It only makes sense to verify when the notification should time out. Thus less code to stub, faster tests, cleaner code.

    0 讨论(0)
  • 2020-11-22 05:07

    Redux itself is a pretty verbose library, and for such stuff you would have to use something like Redux-thunk, which will give a dispatch function, so you will be able to dispatch closing of the notification after several seconds.

    I have created a library to address issues like verbosity and composability, and your example will look like the following:

    import { createTile, createSyncTile } from 'redux-tiles';
    import { sleep } from 'delounce';
    
    const notifications = createSyncTile({
      type: ['ui', 'notifications'],
      fn: ({ params }) => params.data,
      // to have only one tile for all notifications
      nesting: ({ type }) => [type],
    });
    
    const notificationsManager = createTile({
      type: ['ui', 'notificationManager'],
      fn: ({ params, dispatch, actions }) => {
        dispatch(actions.ui.notifications({ type: params.type, data: params.data }));
        await sleep(params.timeout || 5000);
        dispatch(actions.ui.notifications({ type: params.type, data: null }));
        return { closed: true };
      },
      nesting: ({ type }) => [type],
    });
    

    So we compose sync actions for showing notifications inside async action, which can request some info the background, or check later whether the notification was closed manually.

    0 讨论(0)
  • 2020-11-22 05:09

    I would recommend also taking a look at the SAM pattern.

    The SAM pattern advocates for including a "next-action-predicate" where (automatic) actions such as "notifications disappear automatically after 5 seconds" are triggered once the model has been updated (SAM model ~ reducer state + store).

    The pattern advocates for sequencing actions and model mutations one at a time, because the "control state" of the model "controls" which actions are enabled and/or automatically executed by the next-action predicate. You simply cannot predict (in general) what state the system will be prior to processing an action and hence whether your next expected action will be allowed/possible.

    So for instance the code,

    export function showNotificationWithTimeout(dispatch, text) {
      const id = nextNotificationId++
      dispatch(showNotification(id, text))
    
      setTimeout(() => {
        dispatch(hideNotification(id))
      }, 5000)
    }
    

    would not be allowed with SAM, because the fact that a hideNotification action can be dispatched is dependent on the model successfully accepting the value "showNotication: true". There could be other parts of the model that prevents it from accepting it and therefore, there would be no reason to trigger the hideNotification action.

    I would highly recommend that implement a proper next-action predicate after the store updates and the new control state of the model can be known. That's the safest way to implement the behavior you are looking for.

    You can join us on Gitter if you'd like. There is also a SAM getting started guide available here.

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