My state changes between the reducer and the consuming component

Issue

App purpose: The purpose of this React app is to handle scoring of a very specific dart-game. 2 players, each having to reach 33 hits in the fields 20-13, Tpl’s, Dbls’s and Bulls. No points, only the number of hits are counted. The hits are added manually by the players (no automatiion required :)).
Each targetfield has a row of targets and 2 buttons for adding and removing a hit of that target field.

Game UI design

I have implemented the useContext-design for maintaining state, which looks like this:

export interface IMickeyMouseGameState {
    player1 : IPlayer | null,
    player2 : IPlayer | null,
    winner : IPlayer | null,
    TotalRounds : number,
    GameStatus: Status
    CurrentRound: number
}

Other objects are designed like this :

export interface IGame {
    player1?:IPlayer;
    player2?:IPlayer;
    sets: number;   
    gameover:boolean;
    winner:IPlayer|undefined;
}
export interface IPlayer {
    id:number;
    name: string;
    targets: ITarget[];
    wonSets: number;
    hitRequirement : number
}
export interface ITarget {
    id:number,
    value:string,
    count:number
}

export interface IHit{
    playerid:number,
    targetid:number
}

So far so good.

This is the reducer action with the signature:

export interface HitPlayerTarget {
    type: ActionType.HitPlayerTarget,
    payload:IHit
}

const newTargets = (action.payload.playerid === 1 ? [...state.player1!.targets] : [...state.player2!.targets]);

            const hitTarget = newTargets.find(tg => {
                return tg.id === action.payload.targetid;
            });
        
            if (hitTarget) {
                const newTarget = {...hitTarget}
                newTarget.count = hitTarget.count-1;
        
                newTargets.splice(newTargets.indexOf(hitTarget),1);
                newTargets.push(newTarget);
            }

            if (action.payload.playerid === 1) {
                state.player1!.targets = [...newTargets];
            }

            if (action.payload.playerid === 2) {
                state.player2!.targets = [...newTargets];
            }

            
            let newState: IMickeyMouseGameState = {
                ...state,
                player1: {
                    ...state.player1!,
                    targets: [...state.player1!.targets]
                },
                player2: {
                    ...state.player2!,
                    targets: [...state.player2!.targets]
                }
            }
            return newState;

In the Main component i instantiate the useReducerHook:

const MickeyMouse: React.FC = () => {

    const [state, dispatch] = useReducer(mickeyMousGameReducer, initialMickeyMouseGameState);

    const p1Props: IUserInputProps = {
        color: "green",
        placeholdertext: "Angiv Grøn spiller/hold",
        iconSize: 24,
        playerId: 1,
    }

    const p2Props: IUserInputProps = {
        playerId: 2,
        color: "red",
        placeholdertext: "Angiv Rød spiller/hold",
        iconSize: 24,
    }

    return (
        <MickyMouseContext.Provider value={{ state, dispatch }} >

            <div className="row mt-3 mb-5">
                <h1 className="text-success text-center">Mickey Mouse Game</h1>
            </div>

            <MickeyMouseGameSettings />

            <div className="row justify-content-start">
                <div className="col-5">
                    {state.player1 ?<UserTargetList playerid={1} /> : <UserInput {...p1Props} /> }
                </div>
                <div className="col-1 bg-dark text-warning rounded border border-warning">
                    <MickeyMouseLegend />
                </div>
                <div className="col-5">
                    {state.player2 ? <UserTargetList playerid={2} /> : <UserInput {...p2Props} /> }
                </div>
            </div>

        </MickyMouseContext.Provider>
    );
}

export default MickeyMouse;


Now the reducer-action correctly subtracts 1 from the target’s count (the point is to get each target count to 0 and the new state correctly shows the target with 1 less than the old state, but when the Consumer (in this case a tsx-component called UserTargets, which is respnsible for rendering each target with either a circle or an X) the state of the target is 2 lower, even though the reducer only subtracted 1….

After adding a single hit to player ‘Peter’ in the 20-field – the rendering (with consoloe-logs) looks like this:
After clicking the green + button for Peter - field 20

So I guess my question is this : Why is the state mutating between the reducer and the consumer and what can I do to fix it?

If further explanation is needed, please ask, if this question should be simplpified, please let me know…
I usually don’t ask questions here – I mostly find anwers.

The project i available on github: https://github.com/martinmoesby/dart-games

Solution

Issue

I suspect a state mutation in your reducer case is being exposed by the React.StrictMode.

StrictMode – Detecting unexpected side effects

Strict mode can’t automatically detect side effects for you, but it
can help you spot them by making them a little more deterministic.
This is done by intentionally double-invoking the following functions:

  • Class component constructor, render, and shouldComponentUpdate methods
  • Class component static getDerivedStateFromProps method
  • Function component bodies
  • State updater functions (the first argument to setState)
  • Functions passed to useState, useMemo, or useReducer <–

The function being the reducer function.

const newTargets = (action.payload.playerid === 1 // <-- new array reference OK
  ? [...state.player1!.targets]
  : [...state.player2!.targets]);

const hitTarget = newTargets.find(tg => {
  return tg.id === action.payload.targetid;
});
        
if (hitTarget) {
  const newTarget = { ...hitTarget }; // <-- new object reference OK
  newTarget.count = hitTarget.count - 1; // <-- new property OK
        
  newTargets.splice(newTargets.indexOf(hitTarget), 1); // <-- inplace mutation but OK since newTargets is new array
  newTargets.push(newTarget); // <-- same
}

if (action.payload.playerid === 1) {
  state.player1!.targets = [...newTargets]; // <-- state.player1!.targets mutation!
}

if (action.payload.playerid === 2) {
  state.player2!.targets = [...newTargets]; // <-- state.player2!.targets mutation!
}

let newState: IMickeyMouseGameState = {
  ...state,
  player1: {
    ...state.player1!,
    targets: [...state.player1!.targets] // <-- copies mutation
  },
  player2: {
    ...state.player2!,
    targets: [...state.player2!.targets] // <-- copies mutation
  }
}
return newState;

state.player1!.targets = [...newTargets]; mutates and copies in the update into the previous state.player1 state and when the reducer is run again, a second update mutates and copies in the update again.

Solution

Apply immutable update patterns. Shallow copy all state the is being updated.

const newTargets = (action.payload.playerid === 1
  ? [...state.player1!.targets]
  : [...state.player2!.targets]);

const hitTarget = newTargets.find(tg => tg.id === action.payload.targetid);
        
if (hitTarget) {
  const newTarget = {
    ...hitTarget,
    count: hitTarget.count - 1,
  };
        
  newTargets.splice(newTargets.indexOf(hitTarget), 1);
  newTargets.push(newTarget);
}

const newState: IMickeyMouseGameState = { ...state }; // shallow copy

if (action.payload.playerid === 1) {
  newState.player1 = {
    ...newState.player1!, // shallow copy
    targets: newTargets,
  };
}

if (action.payload.playerid === 2) {
  newState.player1 = {
    ...newState.player2!, // shallow copy
    targets: newTargets,
  };
}

return newState;

Answered By – Drew Reese

This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0

Leave a Reply

(*) Required, Your email will not be published