How to cancel/ignore an action in redux

Stoikerty picture Stoikerty · Dec 9, 2015 · Viewed 11.8k times · Source

Is there a way to cancel an action or ignore it?

Or rather what is the best/recommended way to ignore an action?

I have the following action creator and when I input an invalid size (say 'some_string') into the action creator, in addition to getting my own warning message I also get: Uncaught Error: Actions must be plain objects. Use custom middleware for async actions.

import { SET_SELECTED_PHOTOS_SIZE } from './_reducers';

export default (size=0) => {
  if (!isNaN(parseFloat(size))) {
    return {
      type: SET_SELECTED_PHOTOS_SIZE,
      size: size,
    };
  } else {
    app.warn('Size is not defined or not a number');
  }
};

I've discussed this in the redux-channel in Discord (reactiflux) where one suggestion was to use redux-thunk like this:

export default size => dispatch => {
  if (!isNaN(parseFloat(size))) {
    dispatch({
      type: SET_SELECTED_PHOTOS_SIZE,
      size: size,
    });
  } else {
    app.warn('Size is not defined or not a number');
  }
}

The other option was to ignore the action inside the reducer. This does make the reducer "fatter" because it then has more responsibilities, but it uses less thunk-actions which makes it easier to debug. I could see the thunk-pattern getting out of hand since I would be forced to use it for almost every action, making batched actions a bit of a pain to maintain if you have lots of them.

Answer

Tomáš Weiss picture Tomáš Weiss · Dec 9, 2015

Ignoring actions in Action Creators is basically a way of treating them as Command Handlers, not Event Creators. When the User clicks the button it’s some kind of Event though.

So there are basically two ways how to solve the issue:

  1. The condition is inside action creator and thunk-middleware is used

    const cancelEdit = () => (dispatch, getState) => {
      if (!getState().isSaving) {
        dispatch({type: CANCEL_EDIT});
      }
    }
    
  2. The condition is inside reducer and no middleware is required

    function reducer(appState, action) {
      switch(action.type) {
       case: CANCEL_EDIT:
         if (!appState.isSaving) {
           return {...appState, editingRecord: null }
         } else {
           return appState;
         }
       default:
         return appState;
    
      }
    }
    

I strongly prefer treating UI interaction as Events instead of Commands and there two advantages:

  1. All your domain logic stays in the synchronous pure reducers which are very easy to test. Just imagine you would need to write unit test for the functionality.

    const state = {
      isSaving: true,
      editingRecord: 'FOO'
    };
    
    // State is not changed because Saving is in progress
    assert.deepEqual(
      reducer(state, {type: 'CANCEL_EDIT'}),
      state
    );
    
    // State has been changed because Saving is not in progress anymore
    assert.deepEqual(
      reducer({...state, isSaving: false}),
      {isSaving: false, editingRecord: null}
    );
    

As you can see the test is really simply when you treat the interaction as an Event

  1. What if you decided that instead of ignoring the action you would rather show some visual indication that the action is not possible? You would need to dispatch another action or basically rebuild it. However, you can’t use hot-reload with replay here because the logic in action creator is not re-playable. If the logic is in reducer though, you can simply change the behaviour, the reducer will get hot-reloaded and all the events gets replayed. The only event that you dispatch is that user clicked some button and you can’t deny that fact. So unless you drastically change the UI you can always hot-reload with replay.

When you think about any interaction with the UI as an Event then you will get the best possible replay experience, because Events can’t be denied they have just happened.