Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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:

  • I do not want the UI/component/custom hook to have the responsibility to compute when we reach the min/max, because in my use case it can be pretty complex and depend on various parts of the state. So the reducer is the only place that knows whether we did reach the min/max.
  • On the other hand, in order to implement the Main property above, I do need to somehow remember where we clicked on the rectangle, or how many pixels of a given "drag" action was handled, in order to know when to start moving back. But I don’t want to store that in the state as it’s really a UI detail that doesn’t belong there (and also because I have quite a few different situations where I need to do that and my state would become significantly more complex, and unnecessary state changes will be performance heavy).

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:

  • The reducer only needs to know by how many pixels we moved and then return the new state and how many pixels were actually handled (through mutating the action)
  • The custom hook can remember how far off we are from the original position (without having to know why), and then it will simply correct event.movementX to compensate with how much the reducer didn’t handle in previous actions, and then send the correct delta to the reducer.

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?

like image 995
Guillaume Brunerie Avatar asked Oct 15 '22 19:10

Guillaume Brunerie


1 Answers

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.

like image 196
markerikson Avatar answered Oct 24 '22 01:10

markerikson