Reputation: 25
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?
Upvotes: 0
Views: 3071
Reputation: 136
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.
Upvotes: 2