React useReducer bug while updating state array - reactjs

I haven't been in React for a while and now I am revising. Well I faced error and tried debugging it for about 2hours and couldn't find bug. Well, the main logic of program goes like this:
There is one main context with cart object.
Main property is cart array where I store all products
If I add product with same name (I don't compare it with id's right now because it is small project for revising) it should just sum up old amount of that product with new amount
Well, I did all logic for adding but the problem started when I found out that for some reason when I continue adding products, it linearly doubles it up. I will leave github link here if you want to check full aplication. Also, there I will leave only important components. Maybe there is small mistake which I forget to consider. Also I removed logic for summing up amount of same products because that's not neccesary right now. Pushing into state array is important.
Github: https://github.com/AndNijaz/practice-react-
//Context
import React, { useEffect, useReducer, useState } from "react";
const CartContext = React.createContext({
cart: [],
totalAmount: 0,
totalPrice: 0,
addToCart: () => {},
setTotalAmount: () => {},
setTotalPrice: () => {},
});
const cartAction = (state, action) => {
const foodObject = action.value;
const arr = [];
console.log(state.foodArr);
if (action.type === "ADD_TO_CART") {
arr.push(foodObject);
state.foodArr = [...state.foodArr, ...arr];
return { ...state };
}
return { ...state };
};
export const CartContextProvider = (props) => {
const [cartState, setCartState] = useReducer(cartAction, {
foodArr: [],
totalAmount: 0,
totalPrice: 0,
});
const addToCart = (foodObj) => {
setCartState({ type: "ADD_TO_CART", value: foodObj });
};
return (
<CartContext.Provider
value={{
cart: cartState.foodArr,
totalAmount: cartState.totalAmount,
totalPrice: cartState.totalAmount,
addToCart: addToCart,
}}
>
{props.children}
</CartContext.Provider>
);
};
export default CartContext;
//Food.js
import React, { useContext, useState, useRef, useEffect } from "react";
import CartContext from "../../context/cart-context";
import Button from "../ui/Button";
import style from "./Food.module.css";
const Food = (props) => {
const ctx = useContext(CartContext);
const foodObj = props.value;
const amountInput = useRef();
const onClickHandler = () => {
const obj = {
name: foodObj.name,
description: foodObj.description,
price: foodObj.price,
value: +amountInput.current.value,
};
console.log(obj);
ctx.addToCart(obj);
};
return (
<div className={style["food"]}>
<div className={style["food__info"]}>
<p>{foodObj.name}</p>
<p>{foodObj.description}</p>
<p>{foodObj.price}$</p>
</div>
<div className={style["food__form"]}>
<div className={style["food__form-row"]}>
<p>Amount</p>
<input type="number" min="0" ref={amountInput} />
</div>
<Button type="button" onClick={onClickHandler}>
+Add
</Button>
</div>
</div>
);
};
export default Food;
//Button
import style from "./Button.module.css";
const Button = (props) => {
return (
<button
type={props.type}
className={style["button"]}
onClick={props.onClick}
>
{props.children}
</button>
);
};
export default Button;

Issue
The React.StrictMode component is exposing an unintentional side-effect.
See 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 <-- here
The function passed to useReducer is double invoked.
const cartAction = (state, action) => {
const foodObject = action.value;
const arr = [];
console.log(state.foodArr);
if (action.type === "ADD_TO_CART") {
arr.push(foodObject); // <-- mutates arr array, pushes duplicates!
state.foodArr = [...state.foodArr, ...arr]; // <-- duplicates copied
return { ...state };
}
return { ...state };
};
Solution
Reducer functions are to be considered pure functions, taking the current state and an action and compute the next state. In the sense of pure functionality, the same next state should result from the same current state and action. The solution is only add the new foodObject object once, based on the current state.
Note also for the default "case" just return the current state object. Shallow copying the state without changing any data will unnecessarily trigger rerenders.
I suggest also renaming the reducer function to cartReducer so its purpose is more clear to future readers of your code.
const cartReducer = (state, action) => {
switch(action.type) {
case "ADD_TO_CART":
const foodObject = action.value;
return {
...state, // shallow copy current state into new state object
foodArr: [
...state.foodArr, // shallow copy current food array
foodObject, // append new food object
],
};
default:
return state;
}
};
...
useReducer(cartReducer, initialState);
Additional Suggestions
When adding an item to the cart, first check if the cart already contains that item, and if so, shallow copy the cart and the matching item and update the item's value property which appears to be the quantity.
Cart/item totals are generally computed values from existing state. As such these are considered derived state and they don't belong in state, these should computed when rendering. See Identify the minimal (but complete) representation of UI state. They can be memoized in the cart context if necessary.

Related

Parent component state is somehow overwriting itself when a new component is created

My Levels component contains a list of Level components that gets rendered in the UI.
A level is basically a list of items.
From my Levels component, I pass a function to my Level component. Inside the Level component there is a button, when clicked, will call the function I passed from my Levels component called createInnerRange.
So when you click the button, it creates a new list of items.
The new list that was just created also has that button, when clicked should create another list.
The bug is, when the newly created list is rendered, and I click the button to create a new list, it creates a new list but it overwrites the first list I that was just created.
Why is this happening? I am always appending to the Levels state whenever a new Level is created.
import React, { ReactElement, useEffect, useState } from 'react';
export interface LevelProps {
innerRangeHandler?: (a: number, b: number) => void;
}
export interface LevelsState {
levels: Array<LevelProps>;
};
const initialState: LevelsState = {
levels: [],
};
function Levels() {
const [state, setState] = useState<LevelsState>(initialState);
const addLevel = (props: LevelProps) => {
console.log('add level called');
setState({
...state,
levels: [...state.levels, props]
})
}
const newLevelOnClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
addLevel({name: "none"});
}
const createInnerRange = (high: number, low: number) => {
addLevel({ name: `inner-${high}:${low}`, high: high, low: low, innerRangeHandler: createInnerRange});
}
return (
<div>
<div className="flex flex-row">
<div className="flex-[3]">
<div className="flex flex-row">
{state.levels.map((props, i) =>
<div className="m-1" key={`${props.name}-${i}`}><Level {...props} /></div>
)}
</div>
</div>
</div>
</div>
);
}
export default Levels;
my Level component has a button, the onClick handler looks like:
const levelButton = (index: number) => (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const button: HTMLButtonElement = event.currentTarget;
console.log(`clicked level with index=${index}`);
props.innerRangeHandler!(a, b);
}
Every single function that is defined within a functional component, is in theory, only defined on mount, therefore the parameters passed into it are not going to be updated from your state updates. The state of your app is correctly updating, however the values your functions are connected to were defined on mount, and on render everything EXCEPT your functions are updated. There are 2 work arounds to this situation, one is ONLY applicable to useState, and the other is by utilizing useCallback
setState allows you to pass in a function, which will ensure its returning the most up to date currentState value when its updating the state value.
const addLevel = (props: LevelProps) => {
console.log('add level called');
setState(currentState => ({
...currentState,
levels: [...currentState.levels, props]
});
}
useCallback is incredibly similar to useEffect in that its watching the data that lives within the component, and when it detects a change to one of its dependencies it updates the values associated with the function to make sure the functions references are the most up to date references.
const addLevel = (props: LevelProps, currentState: any) => {
console.log('add level called');
setState({
...currentState,
levels: [...currentState.levels, props]
});
}
const newLevelOnClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
addLevel({ name: 'none' }, state);
},
[state]
);
In theory, I think just having the correct state from the addLevel function is enough to solve your issue. But useCallback is the appropriate way to define a function that has potentially changing data, it may be an over optimization in this context, but someone may into this issue again, so I wanted to give a well rounded solution to THE problem rather than just your problem

React-component is not re-rendered when the store is changed, neither automatically nor even by force update

This functional component should display a sorted list with checkboxes at each item that change the values in the store.
For some reason it is not re-rendered when the store is changed. And without a re-renderer, it (and the whole application) works very crookedly and halfway. I suspect that this is because the store object remains the same, albeit with new content. But I don’t understand how to fix it. I have even inserted a force update to the checkbox handler, but for some reason it does not work too.
Component:
import React, { useState, useReducer } from 'react';
import { ReactSortable } from 'react-sortablejs';
import ListItem from '#mui/material/ListItem';
import Checkbox from '#mui/material/Checkbox';
import { connect } from 'react-redux';
import { setGameVisible, setGameInvisible } from '../store/actions/games';
interface IGamesListProps {
games: [];
setGameVisible: (id: string) => void;
setGameInvisible: (id: string) => void;
}
interface ItemType {
id: string;
name: string;
isVisible: boolean;
}
const GamesList: React.FunctionComponent<IGamesListProps> = ({games, setGameVisible, setGameInvisible}) => {
const [state, setState] = useState<ItemType[]>(games);
// eslint-disable-next-line
const [ignored, forceUpdate] = useReducer(x => x + 1, 0); // this way of force updating is taken from the official React documentation (but even it doesn't work!)
const onCheckboxChangeHandle = (id: string, isVisible: boolean) => {
isVisible ? setGameInvisible(id) : setGameVisible(id);
forceUpdate(); // doesn't work :(((
}
return (
<ReactSortable list={state} setList={setState} tag='ul'>
{state.map((item) => (
<ListItem
sx={{ maxWidth: '300px' }}
key={item.id}
secondaryAction={
<Checkbox
edge="end"
onChange={() => onCheckboxChangeHandle(item.id, item.isVisible)}
checked={item.isVisible}
/>
}
>
{item.name}
</ListItem>
))}
</ReactSortable>
);
};
export default connect(null, { setGameVisible, setGameInvisible })(GamesList);
Reducer:
import { SET_GAMES, SET_GAME_VISIBLE, SET_GAME_INVISIBLE } from '../actions/games';
export const initialState = {
games: [],
};
export default function games(state = initialState, action) {
switch(action.type) {
case SET_GAMES: {
for(let obj of action.payload.games) {
obj.isVisible = true;
}
return {
...state,
games: action.payload.games,
};
}
case SET_GAME_VISIBLE: {
for(let obj of state.games) {
if (obj.id === action.payload.id) {
obj.isVisible = true;
};
}
return {
...state,
};
}
case SET_GAME_INVISIBLE: {
for(let obj of state.games) {
if (obj.id === action.payload.id) {
obj.isVisible = false;
};
}
return {
...state,
};
}
default:
return state;
}
}
Thank you for any help!
Note: By the information You gave I came with the idea of the problem, but I posted here because it is going to be explanatory and long.
First of all, you don't pass the new game via mapStateToProps into Component in a state change, and even you do, useState won't use new game prop value for non-first render. You must use useEffect and trigger changes of the game and set the to state locally.
At this point you must find the inner state redundant and you can remove it and totally rely on the redux state.
const mapStateToProp = (state) => ({
games: state.games // you may need to change the path
})
connect(mapStateToProp, { setGameVisible, setGameInvisible })(GamesList);
Second, the reducer you made, changes the individual game item but not the games list itself. because it is nested and the reference check by default is done as strict equality reference check-in redux state === state. This probably doesn't cause an issue because the outer state changes by the way, but I think it worth it to mention it.
for(let obj of action.payload.games) {
obj.isVisible = true; // mutating actions.payload.games[<item>]
}
return {
...state,
games: [...action.payload.games], // add immutability for re-redenr
};
// or use map
return {
...state,
games: action.payload.games.map(obj => ({...obj, isVisible:true})),
};
Third, It's true your forceUpdate will cause the component to re-render, and you can test that by adding a console.log, but it won't repaint the whole subtree of your component including inner children if their props don't change that's because of performance issue. React try to update as efficiently as possible. Also you the key optimization layer which prevent change if the order of items and id of them doesn't change

How Exactly useselector works?

As per Doc
useSelector(selector: Function, equalityFn?: Function)
useSelector accepts two params and second one is optional since by default it compare strict === reference equality check but not shallow equality.
const state = useSelector(state => {
console.log("useSelector rerendering");
return state.counter
}, shallowEqual)
Another is
const state = useSelector(state => {
console.log("useSelector rerendering");
return state.counter
})
In both Cases component rerendering when redux store state changes and also when local state changes then it will render (print console.log inside useSelector)
I really didn't understand exactly how it works
Full source code
import React, { useState } from 'react'
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
import { decrement, increment } from './store/actions'
export default function CounterHooks(props) {
const [submit, setSubmit] = useState(false)
const state = useSelector(state => {
console.log("useSelector rerendering");
return state.counter
}, shallowEqual)
const dispatch = useDispatch()
console.log("component rerendering");
const increments = () => {
dispatch(increment());
}
const decrements = () => {
dispatch(decrement());
}
const submitButton = () => {
console.log("component submit", submit);
setSubmit((previousState) => !previousState)
}
return (
<div>
<button onClick={increments} >Incrmeent Counter</button>
<br />
<button onClick={decrements} >Decrement Counter</button>
<br />
<button onClick={submitButton} >Submit</button>
<br />
<h2>total : {state.count}</h2> <br />
<h2>Submit:{String(submit)}</h2> <br />
</div>
)
}
MY question is how exactly second param works ?
In your example, it does not make a difference.
shallowEquals makes sense when you select an object that might be similar in contents, but different by reference.
See these two objects:
const a = { foo: "bar" }
const b = { foo: "bar" }
console.log( a === b ) // will log false
console.log( shallowEquals(a, b)) // will log true
While a and b are two objects with similar shape and contents, they are not the same object.
Now shallowEquals does a === comparison between a.foo and b.foo and since both are strings with the same content, a.foo === b.foo will be true.
This does play a role if you create a new object in your selector, say
const result = useSelector((state) => {
return { a: state.foo.bar, b: state.baz.boo }
})
The result of this will always be a new object, so per default useSelector will always assume they are different, even when state.foo.bar and state.baz.boo actually did not change.
If you use a shallowEqual, useSelector will look at the direct (only 1 level deep) child properties of the objects and compare those. Then it will notice that they are in fact equal and not rerender.
Consider the below as reducer,
const userSlice = (
{name: "user", initialState: {name: "test", isLoggedIn: true},
reducers: {updateUser(state, action) => (state.isLoggedIn=action.payload["isLoggedIn"])}
}
If we dispatch an action to reducer with same data like below,
const result = useSelector(state => state.users)
dispatch({isLoggedIn: true})
The above code will not cause the component to re-render, since we the state.users we are referring is same as previous one.
We can make it to re-render with the below changes,
const result = useSelector(state => ({name: state.users.name, isLoggedIn: state.users.isLoggedIn)
dispatch({isLoggedIn: true})
In the above code, we are returning an object directly so whenever the useSelector runs the returned objects will not be same due to referential equality. This make component to re-render
With useSelector you can also override the default strategy for rendering.
This hook takes an optional equality comparison function as the second parameter that allows you to customize the way the selected state is compared to determine whether the component needs to be re-rendered.*
https://github.com/reduxjs/react-redux/blob/master/src/hooks/useSelector.ts

Implement useSelector equivalent for React Context?

There's a bunch of articles out there that show how Redux can be replaced with context and hooks (see this one from Kent Dodds, for instance). The basic idea is to make your global state available through a context instead of putting it inside a Redux store. But there's one big problem with that approach: components that subscribe to the context will be rerendered whenever any change happens to the context, regardless of whether or not your component cares about the part of the state that just changed. For functional components, React-redux solves this problem with the useSelector hook. So my question is: can a hook like useSelector be created that would grab a piece of the context instead of the Redux store, would have the same signature as useSelector, and, just like useSelector, would only cause rerenders to the component when the "selected" part of the context has changed?
(note: this discussion on the React Github page suggests that it can't be done)
No, it's not possible. Any time you put a new context value into a provider, all consumers will re-render, even if they only need part of that context value.
That's specifically one of the reasons why we gave up on using context to propagate state updates in React-Redux v6, and switched back to using direct store subscriptions in v7.
There's a community-written React RFC to add selectors to context, but no indication the React team will actually pursue implementing that RFC at all.
As markerikson answers, it is not possible, but you can work around it without using external dependencies and without falling back to doing manual subscriptions.
As a workaround, you can let the component re-render, but skip the VDOM reconciliation by memoizing the returned React element with useMemo.
function Section(props) {
const partOfState = selectPartOfState(useContext(StateContext))
// Memoize the returned node
return useMemo(() => {
return <div>{partOfState}</div>
}, [partOfState])
}
This is because internally, when React diffs 2 versions of virtual DOM nodes, if it encountered the exact same reference, it will skip reconciling that node entirely.
I created a toolkit for managing state using ContextAPI. It provides useSelector (with autocomplete) as well as useDispatch.
The library is available here:
https://www.npmjs.com/package/react-context-toolkit
https://github.com/bergkvist/react-context-toolkit
It uses:
use-context-selector to avoid unneccesary rerenders.
createSlice from #reduxjs/toolkit to make the state more modular and to avoid boilerplate.
I've created this small package, react-use-context-selector, and it just does the job.
I used the same approach as used in Redux's useSelector. It also comes with type declarations and the return type matches the selector function's return type making it suitable for using in TS project.
function MyComponent() {
// This component will re-render only when the `name` within the context object changes.
const name = useContextSelector(context, value => value.name);
return <div>{name}</div>;
}
Here is my take on this problem:
I used the function as child pattern with useMemo to create a generic selector component:
import React, {
useContext,
useReducer,
createContext,
Reducer,
useMemo,
FC,
Dispatch
} from "react";
export function createStore<TState>(
rootReducer: Reducer<TState, any>,
initialState: TState
) {
const store = createContext({
state: initialState,
dispatch: (() => {}) as Dispatch<any>
});
const StoreProvider: FC = ({ children }) => {
const [state, dispatch] = useReducer(rootReducer, initialState);
return (
<store.Provider value={{ state, dispatch }}>{children}</store.Provider>
);
};
const Connect: FC<{
selector: (value: TState) => any;
children: (args: { dispatch: Dispatch<any>; state: any }) => any;
}> = ({ children, selector }) => {
const { state, dispatch } = useContext(store);
const selected = selector(state);
return useMemo(() => children({ state: selected, dispatch }), [
selected,
dispatch,
children
]);
};
return { StoreProvider, Connect };
}
Counter component:
import React, { Dispatch } from "react";
interface CounterProps {
name: string;
count: number;
dispatch: Dispatch<any>;
}
export function Counter({ name, count, dispatch }: CounterProps) {
console.count("rendered Counter " + name);
return (
<div>
<h1>
Counter {name}: {count}
</h1>
<button onClick={() => dispatch("INCREMENT_" + name)}>+</button>
</div>
);
}
Usage:
import React, { Reducer } from "react";
import { Counter } from "./counter";
import { createStore } from "./create-store";
import "./styles.css";
const initial = { counterA: 0, counterB: 0 };
const counterReducer: Reducer<typeof initial, any> = (state, action) => {
switch (action) {
case "INCREMENT_A": {
return { ...state, counterA: state.counterA + 1 };
}
case "INCREMENT_B": {
return { ...state, counterB: state.counterB + 1 };
}
default: {
return state;
}
}
};
const { Connect, StoreProvider } = createStore(counterReducer, initial);
export default function App() {
return (
<StoreProvider>
<div className="App">
<Connect selector={(state) => state.counterA}>
{({ dispatch, state }) => (
<Counter name="A" dispatch={dispatch} count={state} />
)}
</Connect>
<Connect selector={(state) => state.counterB}>
{({ dispatch, state }) => (
<Counter name="B" dispatch={dispatch} count={state} />
)}
</Connect>
</div>
</StoreProvider>
);
}
Working example: CodePen
Solution with external store (Redux or Zustand like approach) with new hook useSyncExternalStore comes with React 18.
For React 18: Define createStore and useStore functions:
import React, { useCallback } from "react";
import { useSyncExternalStore } from "react";
const createStore = (initialState) => {
let state = initialState;
const getState = () => state;
const listeners = new Set();
const setState = (fn) => {
state = fn(state);
listeners.forEach((l) => l());
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
};
const useStore = (store, selector) =>
useSyncExternalStore(
store.subscribe,
useCallback(() => selector(store.getState()), [store, selector])
);
Now use it :
const store = createStore({ count: 0, text: "hello" });
const Counter = () => {
const count = useStore(store, (state) => state.count);
const inc = () => {
store.setState((prev) => ({ ...prev, count: prev.count + 1 }));
};
return (
<div>
{count} <button onClick={inc}>+1</button>
</div>
);
};
For React 17 and any React version that supports hooks:
Option 1: You may use the external library (maintained by React team)
use-sync-external-store/shim :
import { useSyncExternalStore } from "use-sync-external-store/shim";
Option 2: If you don't want to add new library and don't care about concurency problems:
const createStore = (initialState) => {
let state = initialState;
const getState = () => state;
const listeners = new Set();
const setState = (fn) => {
state = fn(state);
listeners.forEach((l) => l());
}
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
return {getState, setState, subscribe}
}
const useStore = (store, selector) => {
const [state, setState] = useState(() => selector(store.getState()));
useEffect(() => {
const callback = () => setState(selector(store.getState()));
const unsubscribe = store.subscribe(callback);
callback();
return unsubscribe;
}, [store, selector]);
return state;
}
Sources:
A conference talk from Daishi Kato from React Conf 2021
A blog post about same conference talk by Chetan Gawai
Simple approach to prevent additional renders with HoC and React.memo:
const withContextProps = (WrappedComponent) => {
const MemoizedComponent = React.memo(WrappedComponent);
return (props) => {
const state = useContext(myContext);
const mySelectedState = state.a.b.c;
return (
<MemoizedComponent
{...props}
mySelectedState={mySelectedState} // inject your state here
/>
);
};
};
withContextProps(MyComponent)
I have made a library, react-context-slices, which can solve what you are looking for. The idea is to break the store or state in slices of state, that is, smaller objects, and create a context for each one. That library which I told you does this, exposes a function createSlice which accepts a reducer, initial state, name of the slice, and a function for creating the actions. You create as slices as you want ('todos', 'counter', etc) and integrate them in a unique interface easily, exposing at the end two custom hooks, useValues and useActions, which can 'attack' all the slices (that is, in your client components you do not use useTodosValues but useValues). The key is that useValues accepts a name of the slice, so would be equivalent to the useSelector from redux. The library use immer as redux does. It's a very tiny library which the key point is how is used, which is explained in the readme file. I have also made a post about it. The library exposes only two functions, createSlice and composeProviders.

Communication Parent and Child component with useEffect

I have issues with communication between a parent and a child component.
I would like the parent (Host) to hold his own state. I would like the child (Guest) to be passed that state and modify it. The child has his local version of the state which can change however the child wants. However, once the child finishes playing with the state, he passes it up to the parent to actually "Save" the actual state.
How would I correctly implement this?
Issues from my code:
on the updateGlobalData handler, I log both data and newDataFromGuest and they are the same. I would like data to represent the old version of the data, and newDataFromGuest to represent the new
updateGlobalData is being called 2X. I can solve this by removing the updateGlobalData ref from the deps array inside useEffect but I don't want to heck it.
My desired results should be:
the data state should hold the old data until updateGlobalData is called
I want updateGlobalData to be fired only once when I click the button
Code from Codesandbox:
import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
const Host = () => {
const [data, setData] = useState({ foo: { bar: 1 } });
const updateGlobalData = newDataFromGuest => {
console.log(data);
console.log(newDataFromGuest);
setData(newDataFromGuest);
};
return <Guest data={data} updateGlobalData={updateGlobalData} />;
};
const Guest = ({ data, updateGlobalData }) => {
const [localData, setLocalData] = useState(data);
const changeLocalData = newBarNumber => {
localData.foo = { bar: newBarNumber };
setLocalData({ ...localData });
};
useEffect(() => {
updateGlobalData(localData);
}, [localData, updateGlobalData]);
return (
<div>
<span>{localData.foo.bar}</span> <br />
<button onClick={() => changeLocalData(++localData.foo.bar)}>
Increment
</button>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<Host />, rootElement);
NOTE: Code solution below
Problem 1:
I want updateGlobalData to be fired only once when I click the button
To solve this issue, I have used a mix between React.createContext and the hook useReducer. The idea is to make the Host dispatcher available through its context. This way, you do not need to send the "updateGlobalData" callback down to the Guest, nor make the useEffect hook to be dependant of it. Thus, useEffect will be triggered only once.
Note though, that useEffect now depends on the host dipatcher and you need to include it on its dependencies. Nevertheless, if you read the first note on useReducer, a dispatcher is stable and will not cause a re-render.
Problem 2:
the data state should hold the old data until updateGlobalData is called
The solution is easy: DO NOT CHANGE STATE DATA DIRECTLY!! Remember that most values in Javascript are passed by reference. If you send data to the Guest and you directly modify it, like here
const changeLocalData = newBarNumber => {
localData.foo = { bar: newBarNumber }; // YOU ARE MODIFYING STATE DIRECTLY!!!
...
};
and here
<button onClick={() => changeLocalData(++localData.foo.bar)}> // ++ OPERATOR MODIFYES STATE DIRECLTY
they will also be modified in the Host, unless you change that data through the useState hook. I think (not 100% sure) this is because localData in Guest is initialized with the same reference as data coming from Host. So, if you change it DIRECTLY in Guest, it will also be changed in Host. Just add 1 to the value of your local data in order to update the Guest state, without using the ++ operator. Like this:
localData.foo.bar + 1
This is my solution:
import React, { useState, useEffect, useReducer, useContext } from "react";
import ReactDOM from "react-dom";
const HostContext = React.createContext(null);
function hostReducer(state, action) {
switch (action.type) {
case "setState":
console.log("previous Host data value", state);
console.log("new Host data value", action.payload);
return action.payload;
default:
throw new Error();
}
}
const Host = () => {
// const [data, setData] = useState({ foo: { bar: 1 } });
// Note: `dispatch` won't change between re-renders
const [data, dispatch] = useReducer(hostReducer, { foo: { bar: 1 } });
// const updateGlobalData = newDataFromGuest => {
// console.log(data.foo.bar);
// console.log(newDataFromGuest.foo.bar);
// setData(newDataFromGuest);
// };
return (
<HostContext.Provider value={dispatch}>
<Guest data={data} /*updateGlobalData={updateGlobalData}*/ />
</HostContext.Provider>
);
};
const Guest = ({ data /*, updateGlobalData*/ }) => {
// If we want to perform an action, we can get dispatch from context.
const hostDispatch = useContext(HostContext);
const [localData, setLocalData] = useState(data);
const changeLocalData = newBarNumber => {
// localData.foo = { bar: newBarNumber };
// setLocalData({ ...localData });
setLocalData({ foo: { bar: newBarNumber } });
};
useEffect(() => {
console.log("useEffect", localData);
hostDispatch({ type: "setState", payload: localData });
// updateGlobalData(localData);
}, [localData, hostDispatch /*, updateGlobalData*/]);
return (
<div>
<span>{localData.foo.bar}</span> <br />
<button onClick={() => changeLocalData(localData.foo.bar + 1)}>
Increment
</button>
</div>
);
};
const rootElement = document.getElementById("root");
ReactDOM.render(<Host />, rootElement);
If you see anything not matching with what you want, please, let me know and I will re-check it.
I hope it helps.
Best,
Max.

Resources