0

In my react app, I'm getting this strange error ("Cannot update a component (xxx) while rendering a different component (yyy)"). I understand what is causing the error, but I don't understand the "why" or how to fix it without restructuring a large portion of logic. So the components in the lifecycle and the underlying logic are as follows: "App" is the top level component, which contains state to an object called "grid". This state and its setter is passed down to a component called "Grid2". Grid2 also has its own state interfaced by a reducer (React.useReducer not React.useState). This reducer is passed the App State (and the grid obj inside of the state) as well as the setter to this state. So the reducer not only returns updated state for Grid2's state, but also may invoke the setter for App's state. React does not like this, but my only intuitive solution would be to move all of the logic that invokes the App's setter into useEffects which would be listening for changes on Grid2's state.

//--------------- App.tsx ---------------------
export const AppContext = React.createContext<AppContextType>({refs: initAppRefs, state: initAppState, setState: () => {}});

export function App() {
  let { current: refs } = React.useRef<Refs>(initAppRefs);
  const [state, setState] = React.useState<State>(initAppState);
  return (
    <AppContext.Provider value={{refs, state, setState}}>
      <Home />
    </AppContext.Provider>
  );
}

//---------------- Grid2.tsx --------------------
import { AppContext, AppContextType, State } from "../App";
const gridStateReducer = (last: GridState, action: GridReducerAction): GridState => {
  const newState: GridState = Helpers.deepCopy(last);
  // centralized setter for tile.mouseDown, returns if change was made
  const mouseDownOverride = (tile: string, value: boolean): boolean => {
    // force tile to exist in newState.grid
    if (!(tile in newState.grid)) {
      newState.grid[tile] = {mouseDown: false, mouseOver: false};
    }
    // check to see if change is needed
    if (newState.grid[tile].mouseDown !== value) {
      newState.grid[tile].mouseDown = value;
      // update appState grid fills
      if (value) { //mousedown
        if (tile in action.appState.grid) {
          if (action.appState.currTool === "wall" && action.appState.grid[tile].fill === "empty") {
            const newAppState: State = Helpers.deepCopy(action.appState);
            newAppState.grid[tile].fill = "wall";
            action.setAppState(newAppState);
          }
        }
      }
      return true;
    } else {
      return false;
    }
  }
  if (action.type === GridReducerActionType.SetTileDown && action.data instanceof Array 
  && typeof action.data[0] === "string" && typeof action.data[1] === "boolean") {
    return mouseDownOverride(...(action.data as [string, boolean])) ? newState : last;
  }
}
export const Grid2: React.FC<{}> = () => {
  const { state: appState, setState: setAppState, refs: appRefs } = React.useContext<AppContextType>(AppContext);

  const [gridState, gridStateDispatch] = React.useReducer(gridStateReducer, initGridState);
}

The code is a very selective set of logic from the actual project, as you may notice a lot of references seemingly appearing from nowhere, but I omitted this code as it just bloats the code and takes away from the logic path. So my question is, why does this happen (looking for an under-the-hood explanation), and how do I fix this without refactoring it too much?

1
  • tdlr Op create a React.useReducer reducer function with an action that modified state outside of it own state. The below answer states that a reducer function is not allowed to have side effects. Commented May 31, 2022 at 20:03

1 Answer 1

2

By my estimation, the problem is probably due to side-effects in the gridStateReducer. The reducer functions passed to useReducer shouldn't have side-effects (i.e. call any setters or mutate any global state). The point of a reducer function is to take the current state, apply an action payload, and then return a new state, which will then prompt the React lifecycle to do whatever re-renders are necessary.

Since you're calling action.setAppState(newAppState) inside the reducer, and since that's a React state setter, my guess is that that's causing React to kick off a new render cycle before the reducer can finish. Since that new render cycle would cause components to update, it could then "cause a component to update (probably App) while rendering a different component (whatever is calling gridStateDispatch or invoking that reducer, probably Grid2)"

In terms of refactor, the requirement is that gridStateReducer return a new GridState and not cause any side-effects. First thing is probably to refactor the reducer to remove the side-effect and just return a new state:

const gridStateReducer = (last: GridState, action: GridReducerAction): GridState => {
  const newState: GridState = Helpers.deepCopy(last);
  // centralized setter for tile.mouseDown, returns if change was made
  const mouseDownOverride = (tile: string, value: boolean): boolean => {
    // force tile to exist in newState.grid
    if (!(tile in newState.grid)) {
      newState.grid[tile] = {mouseDown: false, mouseOver: false};
    }
    // check to see if change is needed
    if (newState.grid[tile].mouseDown !== value) {
      newState.grid[tile].mouseDown = value;
      // update appState grid fills
      return true;
    } else {
      return false;
    }
  }
  if (action.type === GridReducerActionType.SetTileDown && action.data instanceof Array 
  && typeof action.data[0] === "string" && typeof action.data[1] === "boolean") {
    return mouseDownOverride(...(action.data as [string, boolean])) ? newState : last;
  }
}

Now, it looks like that side-effect was interested in if (tile in action.appState.grid), so I'd need some way to have both tile and appState in context. Since I'm not sure what the structure is exactly, I'm assuming appState in the AppContext and action.appState are the same object. If not, then ignore everything after this sentence.

Looking at the reducer, it looks like we're passing the tile in as the first element in a tuple within the action passed to gridStateDispatch, so that means the caller of that function, which seems like Grid2, must know what tile should be at the time that the dispatch function is called. Since that component also has the AppContext in context, you should be able to do something like:

export const Grid2: React.FC<{}> = () => {
  const { state: appState, setState: setAppState, refs: appRefs } = React.useContext<AppContextType>(AppContext);

  const [gridState, gridStateDispatch] = React.useReducer(gridStateReducer, initGridState);

  const handleSomethingWithTile = (tile: string, someBool: boolean) => {
    gridStateDispatch({ type: GridReducerActionType.SetTileDown, data: [ tile, someBool ] })
    if (tile in appState.grid) {
      if (appState.currTool === "wall" && appState.grid[tile].fill === "empty") {
        const newAppState: State = Helpers.deepCopy(appState);
        newAppState.grid[tile].fill = "wall";
        setAppState(newAppState);
      }
    }
  }
}

This should be possible because the if (tile in appState.grid) statement doesn't seem to need the intermediate state value in the reducer, so it's possible to just move that decision out of the reducer scope here. This should prevent the sort of "state update in the middle of a state update" problem you have.

I should mention: I'd probably want to do some additional refactor here to help simplify the state logic. It seems like you're probably really close to wanting a tool like redux to help manage state here. Also should include a warning that passing full app state with setters via native React context like you're doing here can have pretty serious performance problems if you're not careful.

Sign up to request clarification or add additional context in comments.

5 Comments

Yes, in fact, action.appState === AppState. You reinforced what I was suspecting, however you brought two interesting points that I think you may know something I don't here. So first you said that I am at the tipping point where redux is necessary. However you are also aware I'm using useContext and useReducer hooks, so is there a reason why redux would be a better option than these? Also you mentioned performance issues related to passing (large? stateful?) objects, through useContext, but I am not aware of any of the potential dangers here. If you would care to elaborate on these points ty!
@CameronHonis Sure! Redux isn't strictly necessary per se, but it and other tools in the Redux ecosystem (thinking something like sagas specifically), can help wrangle complex state interactions like this. W/r/t context performance, it's important to realize that any time that context value changes, which is every time the app state changes in your case, anything that consumes that context also re-renders. So if every component in your app is reading from that context, every component will re-render when the app state changes, even if that change is irrelevant to a given component.
Oh okay right. At this point, I subconsciously monitor cost of re-renders when writing code, so I don't even factor this into the equation. But thank you for your insight, you've been super helpful!
No problem! There's a bunch of resources about context if you google for it. Part of the Redux recommendation was to take advantage of selectors, which help deal with consuming only parts of a big state without re-rendering everything. But it's not the only solution for that. Godspeed!
Redux is not going to solve this particular issue because Redux has the same requirements that reducers don't have any side effects and that actions are just pure data objects. So calling a function on your action still won't be okay. That said, I do love Redux and the Redux Toolkit has some excellent helpers so you don't need to do inefficient things like deepCopy.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.