Description
React Easy State version: 6.3.0
Platform: browser
There is a subtle caveat when state is modified after an async function, or in any situation where the code is called asynchronously outside a React render cycle.
If a reference to new state is held across an async boundary then the new state is not observed and changes to it do no trigger a re-render.
Here is a code snippet that shows the problem. The key is that a local variable counter
is used after the async call. If it is used before the async call it works correctly, or if counterStore
is used after the call to get a reference to the value from the store again then it also works correctly.
import React from 'react';
import { store, view } from '@risingstack/react-easy-state';
const counterStore = store({
counters: []
});
async function longFunction() {}
export default view(() => {
const addCounter = async () => {
counterStore.counters.push({value: 0});
const counter = counterStore.counters[counterStore.counters.length-1];
await longFunction();
counter.value++;
console.log("No re-render here!")
}
const incCounter = () => {
counterStore.counters[counterStore.counters.length-1].value++;
}
return (
<>
<button onClick={() => addCounter()}>Add counter</button>
<button onClick={() => incCounter()}>Increment counter</button>
<ul>
{counterStore.counters.map((counter, index) => (
<li key={index}>{counter.value}</li>
))}
</ul>
</>
);
});
It seems like you could just say "always reference the store when updating a value", but that is very hard to do, especially with complex code. The same above was created by removing all of the complexity and nested function calls that was otherwise hiding the problem.
This is disappointing because it undermines the awesome promise of react-easy-state. I think it is worth trying to solve so code like this can work transparently.
Theory of the problem
I think the problem exists because of this sequence:
addCounter
is called from an event handler, so changes to the state are proxied (observed), but the new values themselves are not wrapped in a proxy.- This means that
counter
is not a proxied variable, even though it appears to have been correctly read from the store. - Without the await function it works fine because the re-render is triggered when the element is added to the
counterStore.counters
array and the render doesn't happen until after the function exits (because React is batching the renders).
Second variation on problem
The same problem can be triggered using setTimeout
too:
import React from "react";
import { store, view } from "@risingstack/react-easy-state";
const counterStore = store({
counters: []
});
export default view(() => {
const addCounter = () => {
setTimeout(() => {
counterStore.counters.push({ value: 0 });
const counter = counterStore.counters[counterStore.counters.length - 1];
setTimeout(() => {
counter.value++;
console.log("No re-render here!");
}, 1000);
}, 1000);
};
const incCounter = () => {
counterStore.counters[counterStore.counters.length - 1].value++;
};
return (
<>
<button onClick={() => addCounter()}>Add counter</button>
<button onClick={() => incCounter()}>Increment counter</button>
<ul>
{counterStore.counters.map(counter => (
<li>{counter.value}</li>
))}
</ul>
</>
);
});