javascriptreactjsreduxseparation-of-concerns

Is it acceptable for a reducer to mutate its action object in order to communicate partial handling of the action?


I’m writing a pretty complex app in React/Redux/Redux Toolkit, and I came across a situation which I’m not really sure how to handle. I found a way to do it, but I’m wondering if it can cause issues or if there is a better way. The short version is that I want the reducer to communicate to the caller without modifying the state, and the only way I’ve found is to mutate the action.

Description:

To simplify, let’s say that I want to implement a horizontal scrollbar (but in reality it’s significantly more complicated). The state contains the current position, a number capped between some min and max values, and the UI draws a rectangle that has that position and that can be clicked and dragged horizontally.

Main property: If the user clicks and drags further than the min/max value, then the rectangle does not move further, but if the user then moves in the other direction, the rectangle should wait until the mouse is back at its original position before starting to move back (exactly like scrollbars behave on most/all operating system).

Keep in mind that my real use case is significantly more complex, I have a dozen of similar situations, sometimes capping between min and max, sometimes snapping every 100 pixels, sometimes more complicated constraints that depend on various parts of the state, etc. I’d like a solution that works in all such cases and that preserves the separation between the UI and the logic.

Constraints:

Problem:

So the reducer is the only part that knows if we reached the min/max, and the only way a reducer usually communicates to the rest of the app is through the state, but I don’t want to communicate that information through the state.

Solution?

I actually managed to find a way to solve it, which seems to work just fine but feels somewhat wrong: mutating the action object in the reducer.

The reducer takes the action "dragged by 10 pixels", realizes that it can only drag by 3 pixels, creates a new state where it has been dragged by 3 pixels, and adds an action.response = 3 field to the action.

Then after my custom hook dispatched the "dragged by 10 pixels" action, it looks at the action.response field of the return value of dispatch to know how much was actually handled, and it remembers the difference with the expected value (in this case it remembers that we are 7 pixels away from the original position). In this way, if at the next mousemove we drag by -9 pixels, my custom hook can add that number to the 7 pixels it remembers, and tell the reducer that we only moved by -2 pixels.

It seems to me that this solution preserves separation of UI/logic perfectly:

It also works just fine with things like snapping at every 100 pixels or such.

The only weird thing is that the reducer mutates the action, which I would assume is not supposed to happen as it should be a pure function, but I couldn’t find any issue with it so far. The app just works, Redux Toolkit doesn’t complain, and the devtools work just fine as well.

Is there any issue with this solution?

Is there another way it could be done?


Solution

  • At a technical level, I can see how this could work. But I'd also agree it feels "icky". Very technically speaking, mutating the action itself qualifies as a "side effect", although it's not one that would meaningfully break the rest of the app.

    It sounds as if the key bit of logic here is more at the "dispatch an action" level. I think you could likely call getState() before and after the dispatch to compare the results, and derive the additional needed data that way. In fact, this might be a good use case for a thunk:

    const processDragEvent = (dragAmountPixels: number) => {
      return (dispatch, getState) => {
        const stateBefore = getState();
        dispatch(dragMoved(dragAmountPixels));
        const stateAfter = getState();
    
        const actualAmountChanged = selectActualDragAmount(stateBefore, stateAfter);
    
        // can return a result from the thunk
        return actualAmountChanged
    
      }
    }
    
    // later, in a hook
    const useMyCustomDragBehavior = () => {
      const dispatch = useDispatch();
      const doSomeDragging = (someDragValue: number) => {
        const actualAmountDragged = dispatch(processDragEvent (someDragValue));
        // do something useful with this info here
      }
    }
    

    This way only the UI layer is concerned with the changes.