I've been working on a SPA for a while and managing my global state with a custom context API, but it's been causing headaches with undesired rerenders down the tree so I thought I'd give react-easy-state a try. So far it's been great, but I'm starting to run into some issues which I assume has to do with the mutability of the global state, something which was easily solved with the custom context api implementation using a lib like immer.
Here's a simplified version of the issue I'm running into: I have a global state for managing orders. The order object primaryOrder has an array of addons into which additional items are added to the order - the list of available addons is stored in a separate store that is responsible for fetching the list from my API. The orderStore looks something like this:
const orderStore = store({
initialized: false,
isVisible: false,
primaryOrder: {
addons: [],
}
})
When a user selects to increases the quantity of an addon item, it's added to the addons array if it isn't already present, and if it is the qty prop of the addon is increased. The same logic applies when the quantity is reduced, except if it reaches 0 then the addon is removed from the array. This is done using the following methods on the orderStore:
const orderStore = store({
initialized: false,
isVisible: false,
primaryOrder: {
addons: [],
},
get orderAddons() {
return orderStore.primaryOrder.addons;
},
increaseAddonItemQty(item) {
let index = orderStore.primaryOrder.addons.findIndex(
(i) => i.id === item.id
);
if (index === -1) {
let updatedItem = {
...item,
qty: 1,
};
orderStore.primaryOrder.addons = [
...orderStore.primaryOrder.addons,
updatedItem,
];
} else {
orderStore.primaryOrder.addons[index].qty += 1;
}
console.log(orderStore.primaryOrder.addons);
},
decreaseAddonItemQty(item) {
let index = orderStore.primaryOrder.addons.findIndex(
(i) => i.id === item.id
);
if (index === -1) {
return;
} else {
// remove the item from the array if value goes 1->0
if (orderStore.primaryOrder.addons[index].qty === 1) {
console.log("removing item from array");
orderStore.primaryOrder.addons = _remove(
orderStore.primaryOrder.addons,
(i) => i.id !== item.id
);
console.log(orderStore.primaryOrder.addons);
return;
}
orderStore.primaryOrder.addons[index].qty -= 1;
}
}
})
The issue I'm running into has to do with the fact that one of my views consuming the orderStore.addons. My Product component is the consumer in this case:
const Product = (item) => {
const [qty, setQty] = useState(0);
const { id, label, thumbnailUrl, unitCost } = item;
autoEffect(() => {
if (orderStore.orderAddons.length === 0) {
setQty(0);
return;
}
console.log({ addons: orderStore.orderAddons });
let index = orderStore.orderAddons.findIndex((addon) => addon.id === id);
console.log({ index });
if (index !== -1) setQty(orderStore.findAddon(index).qty);
});
const Adder = () => {
return (
<div
className="flex"
style={{ flexDirection: "row", justifyContent: "space-between" }}
>
<div onClick={() => orderStore.decreaseAddonItemQty(item)}>-</div>
<div>{qty}</div>
<div onClick={() => orderStore.increaseAddonItemQty(item)}>+</div>
</div>
);
}
return (
<div>
<div>{label} {unitCost}</div>
<Adder />
</div>
)
}
export default view(Product)
The issue occurs when I call decreaseAddonItemQty and the item is removed from the addons array. The error is thrown in the Product component, stating that Uncaught TypeError: Cannot read property 'id' of undefined due to the fact that the array length reads as 2, despite the fact that the item has been removed ( see image below)
My assumption is that the consumer Product is reading the global store before it's completed updating, though of course I could be wrong.
What is the correct approach to use with react-easy-state to avoid this problem?
Seems like you found an auto batching bug. Just wrap your erroneous mutating code in batch until it is fixed to make it work correctly.
import { batch, store } from '#risingstack/react-easy-state'
const orderStore = store({
decreaseAddonItemQty(item) {
batch(() => {
// put your code here ...
})
}
})
Read the "Reactive renders are batched. Multiple synchronous store mutations won't result in multiple re-renders of the same component." section of the repo readme for more info about batching.
And some insight:
React updates are synchronous (as opposed to Angular and Vue) and Easy State (and all other state managers) use React setState behind the scenes to trigger re-renders. This means they are all synchronous too.
setState usually applies a big update at once while Easy State calls a dummy setState whenever you mutate a store property. This means Easy State would unnecessarily re-render way too often. To prevent this we have a batch method that blocks re-rendering until the whole contained code block is executed. This batch is automatically applied to most task sources so you don't have to worry about it, but if you call some mutating code from some exotic task source it won't be batched automatically.
We don't speak about batch a lot because it will (finally) become obsolete once Concurrent React is released. In the meantime, we are adding auto batching to as many places as possible. In the next update (in a few days) store methods will get auto batching, which will solve your issue.
You may wonder how could the absence of batching mess things up so badly. Older transparent reactivity systems (like MobX 4) would simply render the component a few times unnecessarily but they would work fine. This is because they use getters and setters to intercept get and set operations. Easy State (and MobX 5) however use Proxies which 'see a lot more'. In your case part of your browser's array.splice implementation is implemented in JS and Proxies intercept get/set operations inside array.splice. Probably array.splice is doing an array[2] = undefined before running array.length = 2 (this is just pseudo code of course). Without batching this results in exactly what you see.
I hope this helps and solves your issue until it is fixed (:
Edit: in the short term we plan to add a strict mode which will throw when store data is mutated outside store methods. This - combined with auto store method batching - will be the most complete solution to this issue until Concurrent React arrives.
Edit2: I would love to know why this was not properly batched by the auto-batch logic to cover this case with some tests. Is you repo public by any chance?
Related
So, I'm just going through a React course. Learning this framework for the first time so this is likely a dumb question. Here's code I've been given for updating a property on an object stored in state as an array of objects.
const [squares, setSquares] = React.useState(boxes)
function toggle(clickedSquare) {
setSquares(prevSquares => {
return prevSquares.map((square) => {
return square === clickedSquare ? {...square, on: !square.on} : square
})
})
}
...but, the following code I wrote works too and seems simpler, what's wrong with this approach? State values are only shallow immutable. Objects stored in a state array are themselves mutable, as far as I can tell...
const [squares, setSquares] = React.useState(boxes)
function toggle(clickedSquare) {
clickedSquare.on = !clickedSquare.on;
setSquares(prevSquares => [...prevSquares])
}
Also consider this example where I have an array containing deeply nested objects held in state.
[
{
trunk: {
limb: {
branch: {
twig: {
leaf: {
color: "green"
}
}
}
}
}
}
]
I want to change that "green" to "brown". It states in this article Handling State in React that...
Deep cloning is expensive
Deep cloning is typically wasteful (instead, only clone what has actually changed)
Deep cloning causes unnecessary renders since React thinks everything has changed when in fact perhaps only a specific child object has changed.
The thing that has changed in the tree example is just the leaf object. So only that needs to be cloned, not the array and not the whole tree or trunk object. This makes a lot more sense to me. Does anyone disagree?
This still leaves (no pun intended) the question of what bugs can be introduced by updating property values in a state array my way and not cloning the object that has the change? A single concrete example would be very nice just so I can understand better where I can optimize for performance.
clickedSquare.on = !clickedSquare.on; is a state mutation. Don't mutate React state.
The reason the following code is likely working is because it has shallow copied the squares state array which triggers a rerender and exposes the mutated array elements.
function toggle(clickedSquare) {
clickedSquare.on = !clickedSquare.on; // <-- mutation!
setSquares(prevSquares => [...prevSquares]); // new array for Reconciliation
}
It may not have any adverse effects in this specific scenario, but mutating state is a good foot gun and likely to cause potentially difficult bugs to debug/diagnose, especially if their effects aren't seen until several children deeper in the ReactTree.
Just always use the first method and apply the Immutable Update pattern. When updating any part of React state, even nested state, new array and object references need to be created for React's Reconciliation process to work correctly.
function toggle(clickedSquare) {
setSquares(prevSquares => prevSquares.map((square) => // <-- new array
square === clickedSquare
? { ...square, on: !square.on } // <-- new object
: square
));
}
Here, You are using map() method which return only true or false for that condition.
So, You should use filter() method instead of map() method which return filtered data for that condition.
For Example:
const arr = [
{
name: 'yes',
age: 45
},
{
nmae: 'no',
age: 15
}
]
const filterByMap = arr.map(elm => elm.age > 18)
console.log(filterByMap) // outputs --> [ true, false ]
const filterByFilter = arr.filter(elm => elm.age > 18)
console.log(filterByFilter) // outputs --> [ { name: 'yes', age: 45 } ]
I have a context/provider that has a websocket as a state variable. Once the socket is initialized, the onMessage callback is set. The callback is something as follows:
const wsOnMessage = (message: any) => {
const data = JSON.parse(message.data);
setProgress(merge(progress, data.progress));
};
Then in the component I have something like this:
function PVCListTableRow(props: any) {
const { pvc } = props;
const { progress } = useMyContext();
useEffect(() => {
console.log('Progress', progress[pvc.metadata.uid])
}, [progress[pvc.metadata.uid]])
return (
{/* stuff */}
);
}
However, the effect isn't triggering when the progress variable gets updated.
The data structure of the progress variable is something like
{
"uid-here": 0.25,
"another-uid-here": 0.72,
...etc,
}
How can I get the useEffect to trigger when the property that matches pvc.metadata.uid gets updated?
Or, how can I get the component to re-render when that value gets updated?
Quoting the docs:
The function passed to useEffect will run after the render is
committed to the screen.
And that's the key part (that many seem to miss): one uses dependency list supplied to useEffect to limit its invokations, but not to set up some conditions extra to that 'after the render is committed'.
In other words, if your component is not considered updated by React, useEffect hooks just won't be called!
Now, it's not clear from your question how exactly your context (progress) looks like, but this line:
setProgress(merge(progress, data.progress));
... is highly suspicious.
See, for React to track the change in object the reference of this object should change. Now, there's a big chance setProgress just assignes value (passed as its parameter) to a variable, and doesn't do any cloning, shallow or deep.
Yet if merge in your code is similar to lodash.merge (and, again, there's a huge chance it actually is lodash.merge; JS ecosystem is not that big these days), it doesn't return a new object; instead it reassigns values from data.progress to progress and returns the latter.
It's pretty easy to check: replace the aforementioned line with...
setProgress({ ...merge(progress, data.progress) });
Now, in this case a new object will be created and its value will be passed to setProgress. I strongly suggest moving this cloning inside setProgress though; sure, you can do some checks there whether or not you should actually force value update, but even without those checks it should be performant enough.
There seems to be no problem... are you sure pvc.metadata.uid key is in the progress object?
another point: move that dependency into a separate variable after that, put it in the dependency array.
Spread operator create a new reference, so it will trigger the render
let updated = {...property};
updated[propertyname] =value;
setProperty(()=>updated);
If you use only the below code snippet, it will not re-render
let updated = property; //here property is the base object
updated[propertyname] = value;
setProperty(()=>updated);
Try [progress['pvc.metadata.uid']]
function PVCListTableRow(props: any) {
const { pvc } = props;
const { progress } = useMyContext();
useEffect(() => {
console.log('Progress', progress[pvc.metadata.uid])
}, [progress['pvc.metadata.uid']])
return (
{/* stuff */}
);
}
I cant figure out why my setStock function is not updating the state and not causing a re-render, while I have several other functions working just fine.
const addToStockOperation = async (addOperation) => {
const payload = {
...
};
const jwtToken = {
...
};
const addToStockOperationResult = await axios.put(`${apiEndpoint}/stock/addtoitem`, payload, jwtToken);
setStock((prevStock) => {
const indexOfModifiedStock = prevStock.findIndex((stock) => stock._id === addOperation.id);
console.log(prevStock[indexOfModifiedStock].operations.added.length);
prevStock[indexOfModifiedStock].operations.added = addToStockOperationResult.data.operations.added;
console.log(prevStock[indexOfModifiedStock].operations.added.length);
return prevStock;
});
};
Both console logs confirm that the modification of prevStock did happen, as the second console.log shows a length of +1 compared to the previous length, so that indicates that the desired part of prevStock was indeed updated, however, a re-render is not caused.
I have also tried making a copy of prevStock const stockCopy = {...prevStock}; and modifying the copy and returning the copy, but no change.
I have also tried simply to return 1; just to see if a re-render will get triggered, still nothing.
I have a few other similar functions that are working just fine and are causing a re-render as expected:
This one is working just fine for setting products:
const setProductsWrapper = async (product) => {
const addProductResult = await axios.post(
`${apiEndpoint}/product/one`,
payload,
token
);
addProductResult.data.name === product.name &&
setProducts((prevProducts) => [addProductResult.data, ...prevProducts]);
};
EDIT: I found the issue, silly me, stock is an array return [...stockCopy]; after modifying the copy, worked.
Returning prevStock is never going to work because it is the current state array (i.e. has reference equality with it) - you need to return a new array for a new render to be triggered. However, it seems likely that an issue is also arising with mutated state.
You're on the way there when you create the copy const stockCopy = [...prevStock], but the problem is that this only copies the state array to one level of depth. Any objects nested inside it, like .operations, will retain their reference equality to the objects in the original state array.
Mutating them directly means that when you return your copy, any effects which rely on a difference in reference equality between these sub-objects will not run because they are already equal. There is no diff-ing to be done.
To fix this you will have to deeply copy the relevant parts of the tree:
setStock((prevStock) => {
const stockCopy = [...prevStock];
const stockIndex = stockCopy.findIndex((stock) => stock._id === addOperation.id);
stockCopy[stockIndex] = {
...stockCopy[stockIndex],
operations: {
...stockCopy[stockIndex].operations,
added: addToStockOperationResult.data.operations.added
}
};
return stockCopy;
});
State mutation sandbox
This can get quite annoying (and potentially expensive) when the data structure is large enough. It's always better to avoid structures like this in immutable state if you can help it. Of course that's often not the case and there are tools to help deal with immutability that can cut down on bloated code if it starts to become an issue.
I am looking at many answers of checking if elements overlap, but they are not applicable.
I have a wrapper component that has a header with position fixed and children
const Wrapper = ({ children }) => {
return (
<>
<Header/>
{children}
</>
)
};
export default Wrapper
I need to know when the header is overlapping certain parts in several different pages (children) so as to change the header color
I am trying to detect in the wrapper component, but the elements do not exist or are not accesible to the container
Do I need to pass refs all the way from the wrapper to all the children? Is there an easier way to do this?
Thanks
there are a few approaches i can think of, one being the one you mentioned and passing refs around and doing a bunch of calculations and a few others below.
Hardcoded heights of the pages
This would work basically by having a large switch case in you header file and check the offset scroll position of your scroll target.
getBackgroundColor = (scrollPosition) => {
switch (true) {
case scrollPosition <= $('#page1').height:
return 'red'
case scrollPosition <= $('#page1').height + $('#page2').height:
return 'blue'
case scrollPosition <= $('#page1').height + $('#page2').height + $('page3').height
return 'green'
default:
return 'yellow'
}
}
This has obvious flaws, one being, if the page content is dynamic or changes frequently it may not work, checking height every re-render may cause reflow issues, and this requires knowledge of page IDs on the page. (note: this snippet is just to prove concept, it will need tweeks).
Intersection Observer (Recommended way)
Intersection observer is an awesome API that allows you to observe elements in a performant way and reduces layout thrashing from the alternative ways with constant measurements
heres an example I made with react and intersection observer, https://codesandbox.io/s/relaxed-spence-yrozp. This may not be the best if you have hundreds of targets, but I have not ran any benchmarks on that so not certain. Also it is considered "Experimental" but has good browser support (not IE).
here is the bulk of the codesandbox below just to get an idea.
React.useEffect(() => {
let headerRect = document.getElementById("header").getBoundingClientRect();
let el = document.getElementById("wrapper");
let targets = [...document.getElementsByClassName("block")];
let callback = (entries, observer) => {
entries.forEach(entry => {
let doesOverlap = entry.boundingClientRect.y <= headerRect.bottom;
if (doesOverlap) {
let background = entry.target.style.background;
setColor(background);
}
});
};
let io = new IntersectionObserver(callback, {
root: el,
threshold: [0, 0.1, 0.95, 1]
});
targets.forEach(target => io.observe(target));
return () => {
targets.forEach(target => io.unobserve(target));
};
}, []);
ALso notice this is not the most "React" way to do things since it relys a lot on ids, but you can get around that by passing refs everywhere i have used dom selections, but that may become unwieldy.
My instinct tells me no, but I'm having difficultly thinking of a better way.
Currently, I have a component that displays a list of items. Depending on the provided props, this list may change (i.e. filtering change or contextual change)
For example, given a new this.props.type, the state will be updated as follows:
componentWillReceiveProps(nextProps) {
if (nextProps.type == this.state.filters.type) return
this.setState({
filters: {
...this.state.filters,
type: nextProps.type,
},
items: ItemsStore.getItems().filter(item => item.type == nextProps.type)
})
}
This is all fine and good, but now my requirements have changed and I need to add a new filter. For the new filter, I must execute an API call to return a list of valid item ids and I only want to display items with these ids in the same list component. How should I go about this?
I had thought about calling the appropriate action from componentWillReceiveProps, but that doesn't seem right.
componentWillReceiveProps(nextProps) {
if (nextProps.type == this.state.filters.type && nextProps.otherFilter == this.state.filters.otherFilter) return
if (nextProps.otherFilter != this.state.filters.otherFilter) {
ItemsActions.getValidIdsForOtherFilter(nextProps.otherFilter)
// items will be properly updated in store change listener, onStoreChange below
}
this.setState({
filters: {
...this.state.filters,
type: nextProps.type,
otherFilter: nextProps.otherFilter,
},
items: ItemsStore.getItems().filter(item => item.type == nextProps.type)
})
},
onStoreChange() {
let validIds = ItemsStore.getValidIds()
this.setState({
items: ItemsStore.getItems().filter(item => item.type == this.state.filters.type && validIds.indexOf(item.id) > -1)
})
}
Update 22nd January 2018:
Recently an RFC-PR for React was merged, which deprecates componentWillReceiveProps as it can be unsave when used in the upcoming async rendering mode. An example for this can be calling flux actions from this lifecycle hook.
The correct place to call actions (i.e. side effects) is after React is done rendering, so that means either componentDidMount or componentDidUpdate.
If the intention of the action is to fetch data, React might support a new strategy for these things in the future. In the meantime it's safe to stick to the two mentioned lifecycle hooks.