I'm getting started with a new create-react-app application using TypeScript, hooks, and mobx-react-lite. Despite having used MobX extensively in a React Native app in the past, I've run into an issue that doesn't make any sense to me.
I have a store with two observables: one number and one boolean. There is an initialize() method that runs some library code, and in the success callback, it sets the number and the boolean to different values (see Line A and Line B below).
The issue: my component ONLY re-renders itself when Line A is present. In that case, after the initialization is complete, the 'ready' text appears, and the button appears. If I delete Line B, the 'ready' text still appears. But if I delete Line A (and keep Line B), the button never renders. I've checked things over a hundred times, everything is imported correctly, I have decorator support turned on. I can't imagine why observing a number can trigger a re-render but observing a boolean cannot. I'm afraid I'm missing something horribly obvious here. Any ideas?
The relevant, simplified code is as follows:
// store/app.store.ts
export class AppStore {
#observable ready = false
#observable x = 5
initialize() {
// Takes a callback
ThirdPartyService.init(() => {
this.ready = true
this.x = 10
})
}
}
// context/stores.ts
const appStore = new AppStore()
const storesContext = React.createContext({
appStore
})
export const useStores = () => React.useContext(storesContext)
// App.tsx
const App = observer(() => {
const { appStore } = useStores()
useEffect(() => {
appStore.initialize()
}, [appStore])
return (
<div>
{ appStore.x === 10 && 'ready' } // <-- Line A
{ appStore.ready && <button>Go</button> } // <-- Line B
</div>
)
}
EDIT: A bit more information. I've added some logging statements to just before the return statement for the App component. I also refactored the button conditional to a const. This may provide more insight:
const button = appStore.ready ? <button>Go</button> : null
console.log('render', appStore.ready)
console.log('button', button)
return (
<div className="App">
<header className="App-header">{button}</header>
</div>
)
When appStore.ready is updated, the component does re-render, but the DOM isn't updated. The console shows 'render' true and shows a representation of the button, as it should, but inspecting the document itself shows no button there. Somehow, though, changing the condition from appStore.ready to appStore.x === 10 does update the DOM.
Turns out I didn't quite give complete information in my question. While I was creating a minimal reproduction, I decided to try dropping the top-level <React.StrictMode> component from index.tsx. Suddenly, everything worked. As it happens, mobx-react-lite#1.5.2, the most up-to-date stable release at the time of my project's creation, does not play nice with Strict Mode. Until it's added to a stable release, the two options are:
Remove strict mode from the React component tree
Use mobx-react-lite#next
Related
I am familiar with using useEffect’s ability to execute once a state variable has been updated.
I am creating this post about on my phone away from home.
My setup is this:
const [dateValue, setDateValue] = useState<Date | null>)(null);
useEffect(() => {
//useUpdateDateValueHere
} ,[dateValue]);
Date input On change method:
const newDate = new Date(e.target.value);
setDateValue(newDate)
Please keep in mind that this was written from Memory and any compilation errors did not exist on the original code.
Anyways, I debugged the date input on change and determined that it was the correct date I was changing it to. I let the setDateValue method execute and I had a breakpoint on the useEffect so it stopped there too. On the use effect I inspected the value and it was the old value and not the updated value.
I understand that react state does not update immediately after the setState methods. However in my experience when useEffect is used to listen for changes on a state variable the state veritable always has the updated value.
When I get home I will add the code that I used. It is apart Of a work project so I can’t share the entire repository.
I’ve never come across this bug and it throws what I know about useEffect our the window.
Hey guys sorry for the delay. I looked at it last night and discovered it was additional code that I had above that was causing the problem:
const DateDataTypeWrapper: React.FC<DateDataTypeWrapperProps> = ({ styles, property, value, isIdentifierProperty }) => {
const parsedDate = new Date(value);
const [dateValue, setDateValue] = useState<Date | null>(null);
const [dateString, setDateString] = useState<string>();
if (parsedDate.toString() !== 'Invalid Date') {
if (dateValue == undefined || dateValue.toString() != parsedDate.toString()) {
setDateValue(parsedDate);
}
}
I don't quite understand how this code was overwriting the setDateValue I was doing before. Regardless, I moved this logic inside of the initializer of the state variable and now everything is working correctly.
Thanks to #Konrad Linkowski and #Alex Wayne for commenting on the question!
I apologize I underestimated the importance of having this code in the original question.
I read this article React Hook to Run Code After Render and came across this line:
React can and will sometimes call your components multiple times before actually rendering them to the screen, so you can’t rely on “one call == one render”.
What did he mean?
I wrote this code
function x() {
console.log("x");
}
function y() {
console.log("y");
return "y";
}
function Silicon() {
console.log("silicon");
return <div></div>;
}
function useDarko() {
const [count1, setCount1] = useState(0);
console.log("useDarko");
return [count1, setCount1];
}
export default function Test0022() {
const [darko, setDarko] = useDarko();
const [count1, setCount1] = useState(0);
x();
useEffect(() => {
if (count1 !== 200)
setTimeout(() => {
setCount1((e) => ++e);
}, 100);
}, [count1]);
return (
<>
<Silicon />
{y()}
</>
);
}
I always see "804 console.log". It works as expected, one call == one render.
For about four years, React has been working to implement a feature called "concurrent mode" (yes, it had been announced long before that article was released). It will be in version 18 of React, which is currently in a release-candidate state.
Concurrent mode allows React to abort a long-running render part way through in order to handle a more important update. This new approach has implications for lifecycle hooks and for the behavior of side effects, and as a result the React team has been training the community to start writing their code in a way that works with concurrent mode. For example, they deprecated several class component lifecycle hooks that would not be safe with the new approach.
And as the article mentions, one of the mental models we need to get used to is that a component may be called multiple times before it actually makes it on to the screen. Your component may get called, run all the way through, and then React realizes it needs to throw out that work and start over. These cases are rare, but to help you catch problems you can use strict mode. Among other things, it will deliberately double-render your components in development builds, to make it easier to spot bugs that only occur when these double-rendering cases occur.
Note that in React 17 this strict-mode double-render overwrites the console.log function so that it has no effect, and as a result it's difficult to see that it's happening.
I am trying to learn React by building a web application. Since I want to learn it step by step, for now I don't use Redux, I use only the React state and I have an issue.
This is my components architecture:
App.js
|
_________|_________
| |
Main.js Side.js
| |
Game.js Moves.js
As you can see, I have the main file called App.js, in the left side we have the Main.js which is the central part of the application which contains Game.js where actually my game is happening. On the right side we have Side.js which is the sidebar where I want to display the moves each player does in the game. They will be displayed in Moves.js.
To be more clear think at the chess game. In the left part you actually play the game and in the right part your moves will be listed.
Now I will show you my code and explain what the problem is.
// App.js
const App = React.memo(props => {
let [moveList, setMovesList] = useState([]);
return (
<React.Fragment>
<div className="col-8">
<Main setMovesList={setMovesList} />
</div>
<div className="col-4">
<Side moveList={moveList} />
</div>
</React.Fragment>
);
});
// Main.js
const Main = React.memo(props => {
return (
<React.Fragment>
<Game setMovesList={props.setMovesList} />
</React.Fragment>
);
});
// Game.js
const Game= React.memo(props => {
useEffect(() => {
function executeMove(e) {
props.setMovesList(e.target);
}
document.getElementById('board').addEventListener('click', executeMove, false);
return () => {
document.getElementById('board').removeEventListener('click', executeMove, false);
};
})
return (
// render the game board
);
});
// Side.js
const Side= React.memo(props => {
return (
<React.Fragment>
<Moves moveList={props.moveList} />
</React.Fragment>
);
});
// Moves.js
const Moves= React.memo(props => {
let [listItems, setListItems] = useState([]);
useEffect(() => {
let items = [];
for (let i = 0; i < props.moveList.length; i++) {
items.push(<div key={i+1}><div>{i+1}</div><div>{props.moveList[i]}</div></div>)
}
setListItems(items);
return () => {
console.log('why this is being triggered on each move?')
};
}, [props.moveList]);
return (
<React.Fragment>
{listItems}
</React.Fragment>
);
});
As you can see on my code, I have defined the state in App.js. On the left side I pass the function which updates the state based on the moves the player does. On the right side I pass the state in order to update the view.
My problem is that on each click event inside Game.js the component Moves.js unmounts and that console.log is being triggered and I wasn't expected it to behave like that. I was expecting that it will unmount only when I change a view to another.
Any idea why this is happening ? Feel free to ask me anything if what I wrote does not make sense.
Thanks for explaining your question so well - it was really easy to understand.
Now, the thing is, your component isn't actually unmounting. You've passed props.movesList as a dependency for the usEffect. Now the first time your useEffect is triggered, it will set up the return statement. The next time the useEffect gets triggered due to a change in props.movesList, the return statement will get executed.
If you intend to execute something on unmount of a component - shift it to another useEffect with an empty dependency array.
answering your question
The answer to your question
"why this is being triggered on each move"
would be:
"because useEffect wants to update the component with the changed state"
But I would be inclined to say:
"you should not ask this question, you should not care"
understanding useEffect
You should understand useEffect as something that makes sure the state is up to date, not as a kind of lifecycle hook.
Imagine for a moment that useEffect gets called all the time, over and over again, just to make sure everything is up to date. This is not true, but this mental model might help to understand.
You don't care if and when useEffect gets called, you only care about if the state is correct.
The function returned from useEffect should clean up its own stuff (e.g. the eventlisteners), again, making sure everything is clean and up to date, but it is not a onUnmount handler.
understanding React hooks
You should get used to the idea that every functional component and every hook is called over and over again. React decides if it might not be necessary.
If you really have performance problems, you might use e.g. React.memo and useCallback, but even then, do not rely on that anything is not called anymore.
React might call your function anyway, if it thinks it is necessary. Use React.memo only as kind of a hint to react to do some optimization here.
more React tips
work on state
display the state
E.g. do not create a list of <div>, as you did, instead, create a list of e.g. objects, and render that list inside the view. You might even create an own MovesView component, only displaying the list. That might be a bit too much separation in your example, but you should get used to the idea, also I assume your real component will be much bigger at the end.
Don’t be afraid to split components into smaller components.
It seems the problem is occurred by Game element.
It triggers addEventListener on every render.
Why not use onClick event handler
/* remove this part
useEffect(() => {
function executeMove(e) {
props.setMovesList(e.target);
}
document.getElementById('board').addEventListener('click', executeMove, false);
})
*/
const executeMove = (e) => {
props.setMovesList(e.target);
}
return (
<div id="board" onClick={executeMove}>
...
</div>
)
If you want to use addEventListener, it should be added when the component mounted. Pass empty array([]) to useEffect as second parameter.
useEffect(() => {
function executeMove(e) {
props.setMovesList(e.target);
}
document.getElementById('board').addEventListener('click', executeMove, false);
}, [])
Logic:
I have a dialog for converting units. It has two stages of choice for the user: units to convert from and units to convert to. I keep this stage as a state, dialogStage, for maintainability as I'm likely going to need to reference what stage the dialog is in for more features in the future. Right now it's being used to determine what action to take based on what unit is clicked.
I also have a state, dialogUnits, that causes the component to rerender when it's updated. It's an array of JSX elements and it's updated via either foundUnitsArray or convertToUnitsArray, depending on what stage the dialog is at. Currently both states, dialogStage and dialogUnits, are updated at the same moment the problem occurs.
Problem:
When choosing the convertTo units, displayConversionTo() was still being called, as though dialogStage was still set to 'initial' rather than 'concertTo'. Some debugging led to confusion as to why the if (dialogStage == 'initial') was true when I'd set the state to 'convertTo'.
I believe that my problem was that the dialogStage state wasn't updated in time when handleUnitClick() was called as it's asynchronous. So I set up a new useEffect that's only called when dialogStage is updated.
The problem now is that the dialog shows no 'convertTo' units after the initial selection. I believe it's now because dialogUnits hasn't updated in time? I've swapped my original problem from one state not being ready to another state not being ready.
Question
How do I wait until both states are updated before continuing to call a function here (e.g. handleUnitClick()?).
Or have I mistaken what the problem is?
I'm new to react and, so far, I'm only familiar with the practice of state updates automatically rerendering a component when ready, unless overridden. Updating dialogUnits was displaying new units in the dialog until I tried to update it only when dialogStage was ready. It feels like an either/or situation right now (in terms of waiting for states to be updated) and it's quite possible I've overlooked something more obvious, as it doesn't seem to fit to be listening for state updates when so much of ReactJs is built around that already being catered for with rerenders, etc.
Component code:
function DialogConvert(props) {
const units = props.pageUnits;
const [dialogUnits, setDialogUnits] = useState([]);
const [dialogStage, setDialogStage] = useState('initial');
let foundUnitsArray = [];
let convertToUnitsArray = [];
units.unitsFound.forEach(element => {
foundUnitsArray.push(<DialogGroupChoice homogName={element} pcbOnClick={handleUnitClick} />);
});
useEffect(() => {
setDialogUnits(foundUnitsArray);
}, []);
useEffect(() => {
if (dialogStage == "convertTo") {
setDialogUnits(convertToUnitsArray);
}
}, [dialogStage]);
function handleClickClose(event) {
setDialogStage('initial');
props.callbackFunction("none");
}
function handleUnitClick(homogName) {
if (dialogStage == "initial") {
// getConversionChoices is an external function that returns an array. This returns fine and as expected
const choices = getConversionChoices(homogName);
displayConversionTo(choices);
} else if (dialogStage == "convertTo") {
// Can't get this far
// Will call a function not displayed here once it works
}
}
function displayConversionTo(choices) {
let canConvertTo = choices[0]["canconvertto"];
if (canConvertTo.length > 0) {
canConvertTo.forEach(element => {
convertToUnitsArray.push(<DialogGroupChoice homogName={element} pcbOnClick={handleUnitClick} />);
});
setDialogStage('convertTo');
}
}
return (
<React.Fragment>
<div className="dialog dialog__convertunits" style={divStyle}>
<h2 className="dialogheader">Convert Which unit?</h2>
<div className='js-dialogspace-convertunits'>
<ul className="list list__convertunits">
{dialogUnits}
</ul>
</div>
<button className='button button__under js-close-dialog' onClick={handleClickClose}>Close</button>
</div>
</React.Fragment>
)
}
So, there are some issues with your implementations:
Using non-state variables to update the state in your useEffect:
Explanation:
In displayConversionTo when you run the loop to push elements in convertToUnitsArray, and then set the state dialogStage to convertTo, you should be facing the issue that the updated values are not being rendered, as the change in state triggers a re-render and the convertToUnitsArray is reset to an empty array because of the line:
let convertToUnitsArray = [];
thus when your useEffect runs that is supposed to update the
dialogUnits to convertToUnitsArray, it should actually set the dialogueUnits to an empty array, thus in any case the updated units should not be visible on click of the initial units list.
useEffect(() => {
if (dialogStage == "convertTo") {
// as your convertToUnitsArray is an empty array
// your dialogue units should be set to an empty array.
setDialogUnits(convertToUnitsArray)
}
}, [dalogStage]);
You are trying to store an array of react components in the state which is not advisable:
http://web.archive.org/web/20150419023006/http://facebook.github.io/react/docs/interactivity-and-dynamic-uis.html#what-components-should-have-state
Also, refer https://stackoverflow.com/a/53976730/10844020
Solution: What you can do is try to save your data in a state, and then render the components using that state,
I have created a code sandbox example how this should look for your application.
I have also made some changes for this example to work correctly.
In your code , since you are passing units as props from parent, can you also pass the foundUnitsArray calculated from parent itself.
setDialogUnits(props.foundUnitsArray);
and remove the below operation,
units.unitsFound.forEach(element => {
foundUnitsArray.push(<DialogGroupChoice homogName={element} pcbOnClick={handleUnitClick} />);
});
Consider a use case: a block with a text inside (text is fetched from store). When text changes - block smoothly goes away and the other block appears.
Pseudo code for better illustration:
import TransitionGroup from 'react-addons-transition-group'
#connect((state) => ({text: state.text}))
class Container extends React.Component {
render() {
return (
<div>
<TransitionGroup>
<Block key={this.props.text}/> // change block when text changes
</TransitionGroup>
</div>
)
}
}
#TransitionWrapper() // pass componentWillEnter through wrapper
#connect((state) => ({text: state.text}), null, null, {withRef: true})
class Block extends React.Component {
componentWillEnter(callback) {
// fancy animations!!!
const el = ReactDOM.findDOMNode(this);
TweenMax.fromTo(el, 1, {
alpha: 0,
}, {
alpha: 1,
onComplete: callback
});
}
componentWillLeave (callback) {
const el = ReactDOM.findDOMNode(this);
TweenMax.to(el, 1, {
alpha: 0,
onComplete: callback
});
}
render() {
return (
<div>{this.props.text}</div>
)
}
}
What happens when state.text changes?
New Block appears, because key is changed; componentWillEnter starts the animation for it. Great.
Old block gets re-rendered and componentWillLeave starts the animation for it.
When first animation finishes re-render happens again.
The issue is the step no 2: old element should disappear with the old data, but due to re-render it changes his content to a new one from store, so user see this:
store.text = 'Foo'. User see one Block with text 'Foo' on the screen.
store.text = 'Bar'. User see two Blocks, both with text 'Bar' on the screen. One block is disappearing.
Animation finishes, user see one Block with text Foo on screen.
I believe using transitions is pretty common nowadays and this should be a common issue, but I was surprised I couldn't find anything related.
Best idea I can think is to "freeze" props on the element when it's about to leave (or passing previous store, so it re-renders with previous data), but it feels hacky to me.
What's the best way to solve this problem?
We met the same problem with redux store, since when data got removed, the props contains nothing, thus, the UI will show no data when unmounting animation is happening.
I think it is hacky to use the old store or state (break the convention of React life Cycle), you can use loading placeholder if no data is available, like
if (!this.props.text){
return <EmptyPlaceholder />
}
also the animation duration is small like 300 milliseconds, the user experience won't be bad.
Alternatively, you need to define a class instance variable like:
componentWillMount(){
if(this.props.text){
this.text = this.prop.text;
}
}
Then render text like
<Block key={this.props.text || this.text}/>
Then the old text will always be there when unmounting animation happened. I tested on my project, it worked very well. Hopefully it will help u, if not please feel free to msg me.