Cannot update a component (xxx) while rendering a different component (yyy) - reactjs

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?

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.

Related

How to re-render a component when a non state object is updated

I have an object which value updates and i would like to know if there is a way to re-render the component when my object value is updated.
I can't create a state object because the state won't be updated whenever the object is.
Using a ref is not a good idea(i think) since it does not cause a re-render when updated.
The said object is an instance of https://docs.kuzzle.io/sdk/js/7/core-classes/observer/introduction/
The observer class doesn't seem to play well with your use case since it's just sugar syntax to manage the updates with mutable objects. The documentation already has a section for React, and I suggest following that approach instead and using the SDK directly to retrieve the document by observing it.
You can implement this hook-observer pattern
import React, { useCallback, useEffect, useState } from "react";
import kuzzle from "./services/kuzzle";
const YourComponent = () => {
const [doc, setDoc] = useState({});
const initialize = useCallback(async () => {
await kuzzle.connect();
await kuzzle.realtime.subscribe(
"index",
"collection",
{ ids: ["document-id"] },
(notification) => {
if (notification.type !== "document" && notification.event !== "write")
return;
// getDocFromNotification will have logic to retrieve the doc from response
setDoc(getDocFromNotification(notification));
}
);
}, []);
useEffect(() => {
initialize();
return () => {
// clean up
if (kuzzle.connected) kuzzle.disconnect();
};
}, []);
return <div>{JSON.stringify(doc)}</div>;
};
useSyncExternalStore, a new React library hook, is what I believe to be the best choice.
StackBlitz TypeScript example
In your case, a simple store for "non state object" is made:
function createStore(initialState) {
const callbacks = new Set();
let state = initialState;
// subscribe
const subscribe = (cb) => {
callbacks.add(cb);
return () => callbacks.delete(cb);
};
// getSnapshot
const getSnapshot = () => state;
// setState
const setState = (fn) => {
state = fn(state);
callbacks.forEach((cb) => cb());
};
return { subscribe, getSnapshot, setState };
}
const store = createStore(initialPostData);
useSyncExternalStore handles the job when the update of "non state object" is performed:
const title = React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().title
);
In the example updatePostDataStore function get fake json data from JSONPlaceholder:
async function updatePostDataStore(store) {
const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${Math.floor(Math.random()*100)+1}`)
const postData = await response.json()
store.setState((prev)=>({...prev,...postData}));
};
My answer assumes that the object cannot for some reason be in React as state (too big, too slow, too whatever). In most cases that's probably a wrong assumption, but it can happen.
I can't create a state object because the state won't be updated whenever the object is
I assume you mean you can't put that object in a React state. We could however put something else in state whenever we want an update. It's the easiest way to trigger a render in React.
Write a function instead of accessing the object directly. That way you can intercept every call that modifies the object. If you can reliably run an observer function when the object changes, that would work too.
Whatever you do, you can't get around calling a function that does something like useState to trigger a render. And you'll have to call it in some way every time you're modifying the object.
const myObject = {};
let i = 0;
let updater = null;
function setMyObject(key, value) {
myObject[key] = value;
i++;
if (updater !== null) {
updater(i);
}
};
Change your code to access the object only with setMyObject(key, value).
You could then put that in a hook. For simplicity I'll assume there's just 1 such object ever on the page.
function useCustomUpdater() {
const [, setState] = useState(0);
useEffect(()=>{
updater = setState;
return () => {
updater = null;
}
}, [setState]);
}
function MyComponent() {
useCustomUpdater();
return <div>I re-render when that object changes</div>;
}
Similarly, as long as you have control over the code that interacts with this object, you could wrap every such call with a function that also schedules an update.
Then, as long as your code properly calls the function, your component will get re-rendered. The only additional state is a single integer.
The question currently lacks too much detail to give a good assessment whether my suggested approach makes sense. But it seems like a very simple way to achieve what you describe.
It would be interesting to get more information about what kind of object it is, how frequently it's updated, and in which scope it lives.

Combine useMachine with useContext

I'm working on a UI project which handles state updates through a shared context, very similar as described here
const {appState, dispatch} = useContext(AppContext);
I'm not toying around with state machines through xstate for some of the components.
const SomeComponent = () => {
const {appState, dispatch} = useContext(AppContext);
const [state,send,service] = useMachine(MyComponentStateMachine);
}
Now, ideally I would like my state machine to dispatch certain events when entering a state. What's the best way for the state machine to get a hold of my AppContext, though?
At the moment, I'm handling this event dispatching on the component itself, observing the state of the state machine and dispatching an event as needed when it enters a certain state:
const SomeComponent = () => {
const {appState, dispatch} = useContext(AppContext);
const [state,send,service] = useMachine(MyComponentStateMachine);
useEffect(() => service.subscribe(state => {
if(state.value == "Some relevant state of MyComponentStateMachine")
dispatch({type: SomeEvent, arg: 12345});
}).unsubscribe, [service]);
}
This works well, but it strikes me as bad design. I'd think it would be cleaner to dispatch this event from the state machine directly, rather than from the component.
Is there any good way for the state machine to get a hold of AppContext?
Would it be sensible to simply create a factory method for the state machine which takes dispatch as an argument, and holds on to it?
I believe there's nothing wrong to call your dispatch function there. Due that you are using context, you wouldn't be able to call the dispatch inside the machine, unless you pass the function as a parameter. You can try that, not sure it that would work.
(You can use actions to trigger side effects on events or state changes)
In that case it would be something like this:
<pre>
//Component
const SomeComponent = () => {
const { appState, dispatch } = useContext(AppContext);
const [ state, send ] = useMachine(MyComponentStateMachine);
useEffect(() => {
if(state.matches("stateName")) {
const data = { type: SomeEvent, arg: 12345 };
send("EVENT_NAME", { callback: dispatch, data })
}
}, [state]);
}
//Machine
const MyComponentStateMachine = Machine({
...
states: {
stateName: {
on: {
EVENT_NAME: "secondState",
},
},
secondState: {
entry: ["actionName"]
}
},
{
actions: {
actionName: (ctx, e) => {
e.callback(e.data);
},
}
}
});
</pre>
*** Also look how I compare the state, that is a cleaner way to read the state value. Also you don't need to subscribe to the service if you are already using the useMachine hook, the hook will trigger a component rerender if the state changes.

Tell react hook that a "nested" object has updated, without copying the whole object?

In my state I use a set to keep track of a selection. The set can grow to quite large size and as such I wish to prevent copying the set constantly.
I am using a hook for state as below, the code is working. However instead of returning a new set, I prefer to return the "old set", update the set in place.
When I do so, however, react doesn't notice the change and redraw events and other effects are not occurring.
const [selected, setSelected] = React.useState<Set<number>>(new Set());
function onSelect(ev: SyntheticEvent<>, checked: boolean, event_id: number) {
setSelected((selected) => {
if (checked) {
if (!selected.has(event_id)) {
selected.add(event_id);
}
} else {
if (selected.has(event_id)) {
selected.delete(event_id);
}
}
return new Set(selected);
})
}
How do I tell react "hey I've updating xyz state variable"?
You can use useCallback like so
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
From the docs: useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders (e.g. shouldComponentUpdate).
Although you can use useRef like so
function useEffectOnce(cb) {
const didRun = useRef(false);
useEffect(() => {
if(!didRun.current) {
cb();
didRun.current = true
}
})
}
object ref does not notify react about changes to the current ref value.

Redux Not Updating in Component Synchonously

I was under the impression that redux was synchronous but it does not seem to be in this case. I have this:
loadData() {
const filterData = {};
Object.values(this.props.filters).map(f => {
filterData[f.filter] = f.selectedValue || '';
});
this.props.fetchData({filters: filterData}); //redux call to load data
}
filterOnSubmit(filter, value, display) {
const newFilter = { filter, selectedValue: value, display };
this.props.updateFilter(newFilter);
this.loadData();
}
But the value of f does not include the selected value that is set BUT when I look at my redux devtools it is there. If I delay the mapping (or delay the loadData via putting it in a setState callback) it is there also.
action and reducer for reference:
case UPDATE_FILTER: {
const newFilter = { ...state[action.payload.filter], ...action.payload };
return { ...state, [action.payload.filter]: newFilter };
}
export function updateFilter(newFilterData) {
return {
type: UPDATE_FILTER,
payload: newFilterData
};
}
Although the redux store update is synchronous, the changes are not reflected in the same React cycle, the updates go though a re-render state, calling update lifecycle method.
After updating the store, you can call the actions that need to be called on store update in lifecycle method like componentDidUpdate (or componentWillReceiveProps which is now soon to be deprecated) or otherwise pass the filters to the loadData function directly
loadData(filters) {
const filterData = {};
Object.values(filters).map(f => {
filterData[f.filter] = f.selectedValue || '';
});
this.props.fetchData({filters: filterData}); //redux call to load data
}
filterOnSubmit(filter, value, display) {
const newFilter = { filter, selectedValue: value, display };
this.props.updateFilter(newFilter);
this.loadData(newFilter);
}
Using componentDidUpdate, you would write it like
componentDidUpdate(prevProps) {
// you have to perform an isEqual check since simply using '===' will not work for comparing arrays
if(!_.isEqual(prevProps.filters, this.props.filters)) {
this.loadData();
}
}
loadData() {
const filterData = {};
Object.values(this.props.filters).map(f => {
filterData[f.filter] = f.selectedValue || '';
});
this.props.fetchData({filters: filterData}); //redux call to load data
}
filterOnSubmit(filter, value, display) {
const newFilter = { filter, selectedValue: value, display };
this.props.updateFilter(newFilter);
}
You're on the right way, use componentDidUpdate to start a new fetch request. A thing you're possibly missing is an if-condition for changes of filter prop.
Docs remind about this:
You may call setState() immediately in componentDidUpdate() but note
that it must be wrapped in a condition like in the example above, or
you’ll cause an infinite loop.
And for your case it might be something like this, depending on the shape of filters:
componentDidUpdate(prevProps) {
if (this.props.filter !== prevProps.filter)
this.loadData();
}
Depending on code outside, you also might consider using a deep comparison inside if. But, of course, the best way would be to stay with === comparison and optimize mapStateToProps from outside (e.g. use meomization).
Do as SAmab said or move this
Object.values(this.props.filters).map(f => {
console.log(f);
filterData[f.filter] = f.selectedValue || '';
});
to your render function.

Redux change to nested object not triggering componentDidUpdate in component

I am struggling to figure out why a change to an object located in the store handled by a redux reducer is not triggering the componentDidUpdate method inside of my react component. I am using the react developer tools and can see the correct store after the state is reduced, and am also using redux logger and can see the correct after state after the reducer makes the change. But the component still never calls the update method.
action
export const GSAP_ANIMATION = 'GSAP_ANIMATION';
export const animateGsap = (key, next) => {
return {
type: GSAP_ANIMATION,
payload: {
key: key,
next: next
}
}
}
reducer
case GSAP_ANIMATION:
return Object.assign({}, state, {
...state,
gsap: {
...state.gsap,
[payload.key]: {
...state.gsap[payload.key],
next: {
...payload.next
}
}
}
});
component connection
const mapStateToProps = (state, ownProps) => {
return {
component: state.priorities.gsap[ownProps.id]
};
}
const mapDispatchToProps = (dispatch) => {
return {
addGsap: (key) => dispatch(actions.addGsap(key))
};
}
GsapComponent = connect(mapStateToProps, mapDispatchToProps)(GsapComponent);
In the GsapComponent I have the componentDidUpdate method, but this method is never called. However, I can see that the value of this.props.component should be correct when I view the component in the chrome extension.
edit
also doing { JSON.stringify(this.props.component) } correctly shows the updated prop values. Nothing in the react component update lifecycle is every triggered though.
I have also tried to use the immutibility-helper from react like so
return update(state, {
gsap: {
[payload.key]: {
$merge: { next: payload.next }
}
}
});
but it still doesn't call the lifecycle method.
GsapComponent source code.
Check this object assign documentation. Section Examples -> Warning for Deep Clone. I think that your reducer return object is === as state object so react can't detect change. Try json.parse(json.stringify) workaround or use immutable-js.

Resources