For the purpose of learning I am trying to breakdown the basics of the flux pattern by implementing it from scratch. I know that I can fix the problem below using a Class Component but why does it work with the class component's setState({ ... })
and not with the hook's setValues
function?
As you can guess, the problem is that the setValues
function does not seem to trigger a re-render with the new state. I have a feeling this has to do with closures, but I don't understand them super well, so I would appreciate any guidance and help.
Store
// store.js
import EventEmitter from "events";
class StoreClass extends EventEmitter {
constructor() {
super();
this.values = []
}
addNow() {
this.values.push(Date.now());
this.emit('change')
}
getAll() {
return this.values;
}
}
const store = new StoreClass();
export default store;
Component
// App.js
import store from './store';
function App() {
const [values, setValues] = useState([]);
const handleClick = () => {
store.addNow();
};
useEffect(() => {
const onChangeHandler = () => {
console.log("Change called");
setValues(() => store.getAll())
};
store.on('change', onChangeHandler);
return () => store.removeAllListeners('change')
}, [values]);
return (
<div>
<button onClick={handleClick}>Click to add</button>
<ul>
{values.map(val => <li key={val}>{val}</li>)}
</ul>
</div>
);
}
After tinkering a few hours more and reading the react docs again, I can finally answer my own question. In short the problem is that the state in the store was not updated immutably.
Redux, which is an implementation of the Flux architecture, makes it very clear in their documentation:
Redux expects that all state updates are done immutably
Therefore, the culprit in the above code is in the addNow
function of the store. The Array.push()
method mutates the existing array by appending an item. The immutable way of doing this would be to either use the Array.concat()
method or the ES6 spread syntax (de-structuring) [...this.values]
and then assigning the new array to the this.values
.
addNow() {
this.values.push(Date.now()); // Problem - Mutable Change
this.values = this.values.concat(Date.now()) // Option 1 - Immutable Change
this.values = [...this.values, Date.now()] // Option 2 - Immutable Change
this.emit('change')
}
To see the effect of each of these changes in the react component, change the setValues
function in the useEffect
hook to the following:
setValues((prev) => {
const newValue = store.getAll()
console.log("Previous:", prev)
console.log("New value:", newValue)
return newValue
})
In the console it will become clear that when the state in the store is changed mutably, after the first button click, the previous state and the new state will always be the same and reference the same array. However when the state in the store is changed immutably, then the previous state and new state do not reference the same array. Besides, in console it will be clear that the previous state array has one less value than the new state array.
According to the react docs for the class component's setState function.
setState() will always lead to a re-render unless shouldComponentUpdate() returns false.
This means that whether the state in the store is changed mutably or immutably, when the change event is fired and the setState
function is called, react will re-render the component and display the "new" state. This however is not the case with the useState
hook.
According to the react docs for the useState hook:
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)
This means that when the the change to the array in the store is done by mutating the original object, react checks if the new state and the previous are the same before deciding to render again. Since the reference for both the previous and current state are the same, react does nothing. To fix this, the change in the store has to be done immutably.