There are cases when your application won’t behave the way you would expect. That is normal, but in that ticket, something is off. Why does the app send the same API request over and over again? It should do it only once. You use Redux, so as soon as it receives the data, it should be all done?
You also made sure that your React component asks for data in the componentDidMount
lifecycle method. Because that function should be called only once in a component’s lifecycle, the repeated call should be impossible. You have no idea what is going on?
There is always an answer, and no, React is behaving as it should. The problem is somewhere else. Let’s find out what is the real issue here.
Jira Ticket
WUCOVID-19-T-01
(Written Under Corona Virus 2019, Ticket 01)
Symptoms
- The app always triggers the same API repeatedly, behaving like an infinite loop.
- The
Spinner
component reloads and keeps going on forever. - The app is unusable.
Triggering Behaviour:
- The API returns a truthy false value. In that case an empty array: “[]”
Main Components
- API to get Data to store in Redux and any middleware to handle async calls, in my case, Thunk.
Parent
Component that can render 2 child components, one at a time:Child
Component
Spinner
Component
Note: You can find the commented version of these code-snippets at the end of this article.
function getSomeOptions(maxAmount) {
return function(dispatch) {
return fetchSomeOptions(maxAmount).then(
(newOptions) => dispatch({type: "[FETCH_SOMEARRAY] SUCCESS", newOptions}),
(error) => dispatch({type: "[FETCH_SOMEARRAY] ERROR", newOptions: [], error})
);
}
}
class Parent extends React.Component {
render () {
return this.props.isLoading ? <Spinner /> : <ConnectedChild />
}
}
function mapParentStateToProps(state) {
return {
isLoading: state.isLoading
}
}
export const ConnectedParent = connect(mapParentStateToProps, Parent);
class Child extends React.Component {
componentDidMount() {
this.props.getSomeOptions(3);
}
render() {
return (
<ul>
{this.props.options.map(item => <li>{item}</li>)}
</ul>
)
}
}
function mapChildStateToProps(state) {
return {
options: state.options
}
}
mapChildDispatchToProps = {
getSomeOptions
}
export const ConnectedChild = connect(mapChildStateToProps, mapChildDispatchToProps, Child);
Breaking down the steps
…by (Pseudo) steps
- 1. The app starts, and mounts the
Parent
component. - 2. The Parent component gets the
isLoading
value from the Redux Store.- 2.a. When there is an API call in progress, the
isLoading
value will be true.
- 2.a. When there is an API call in progress, the
- 3. The Parent component
render
function returns two different component based on theisLoading
value:- 3.a. If
isLoading
is true, the Parent component will render the Spinner component.- 3.a.1. It will display a spinner gif.
- 3.a.2. React will dismount the Child component
- 3.b. If it is false, it will render the Child component.
- 3.b.1. React will then mount the Child component and will call the component’s
componentDidMount
function. - 3.b.2. The
componentDidMount
will call thegetSomeOptions
function - 3.b.3. The
getSomeOptions
function will set theisLoading
value to true. - 3.b.4. Redux will update all the components to let them know, the
isLoading
value changed - 3.b.5. Step 3.a. will activate (Parent renders the
Spinner
component and dismounts thisChild
component)
- 3.b.1. React will then mount the Child component and will call the component’s
- 3.a. If
… explained
When you get data from the Redux store that does not satisfy one of the child components, the child component goes ahead and triggers another call to the Redux store (in componentDidMount
) hoping for a better result. When a Redux call is in progress though, the Parent
displays the Spinner
component instead of the child component, meaning that it destroys the Child
component that triggered a Redux call itself in componentDidMount
.
When the API returns, Redux updates the Parent
component with the new data, then the Parent
displays the Child
component again. But, probably your API returned a data again that does not satisfy the child component, so the child goes ahead and triggers the API call from componentDidMount
that triggers the whole cycle again and keeps repeating itself indefinitely.
React will not complain, since regarding JavaScript everything behaves as expected, there is not a JS infinite-loop, nor calls components in a limited number depth. It just kicks a process in again and again.
Whereas a user only notice there is a problem, seeing that the Spinner
starts over and over again, with a slight flickering, and as a developer you can see in Chromes network tab, that the same API is getting called over and over again, that always returns the same data.
Separation of concerns
Mixed components
When your component handles both the business logic and the view at the same time. It calls Thunk actions that trigger API calls and that updates the Redux Store. It also handles the components inner state for component-specific behavior.
Smart and Dumb components
Or you could call them Container and Presentational components.
It is when you separate the logic from the view. Instead of having one big component that handles API calls, state, redux updates and logically changes what to display, we make a series of components.
One (smart/container component) for handling the logic, owns a state and can manage redux store. And one or more (dumb/presentational) view components, that are only responsible for displaying data received through props and calling functions that were passed down from the parent component.
Solution(s)
If you face a similar problem I would advise to use “Smart and Dumb” option, meaning, separate the logic from the representation and that way you are avoiding scenarios like these. Do not cause a state update circumventing the parents job, if it can destroy the child component that needs the data and tries to get it itself.
If you use the Mixed Components option make sure, none of the parent components are trying to replace one component with another. If you need to hide something, pass down a prop asking the child component to do it itself. That way your component can stay alive during the apps lifecycle and all your lifecycle methods will work as expected. The child componentDidMount will be called once, and you can listen to changes in componentDidUpdate as you would expect.
One more thing
When you experience this problem you most likely will try to log out the change in componentDidUpdate
, because you have this.props.yourValue
and prevProps.yourValue
, that can tell you a lot about how data is traveling through your component.
Let’s suppose it is an integer for the sake of this example. When you have a value of 50 and you change it to 200, it is expected that the prevProps.yourValue
will contain the value 50 and the this.props.yourValue
will contain the new value, 200. You write an if to compare equality. The strange thing will happen if you console.log
out the 2 values. The prevProps.yourValue
will not have the value 50 in it, it will have the new 200 value as well.
In short, both the previous and current values will contain the new value, so you will not be able to catch the state change in an if statement as you would normally do.
And you might think that there is a problem within React, since by the documentation it would not be possible.
But if you ask yourself, what is the lifecycle method that can run and steal the state when prevProps.yourValue
owns the value 50, before componentDidUpdate
, you can realize, it is the componentDidMount
lifecycle method only.
If you try to log out the this.props.yourValue
in componentDidMount
as well, when you change that value, you will see, not one, but 2 logs. One for the componentDidMount
, with the value 50, and a componentDidUpdate
, with two 200 values, one for the previous and one for the current props.
This means, your parent component kills and recreates the child component due to redux store updates for each data change. It is a big problem because your child component that has to work with the data, cannot depend on the lifecycle methods, its inner state, since it gets destroyed all the time.
It is also a big performance hit for your application.
Here is the promised commented code
with some tips:
/**
* Imagine that in our scenario, the empty array means, "we need some data, fetch it now"
* But, to open the door for the undesired behaviour, when an error occurs we reset it to default empty array.
* In that way we introduced enough possibilities for confusion, that can cause a problem described in that article.
*/
function getSomeOptions(maxAmount) {
// Thunk function to save data in Redux Store.
return function(dispatch) {
// get data (the options that our child component will need) from server
// here we have 2 cases for error:
// 1. We get a success response with an empty array (with no options to use later)
// 2. We get an error, we reset the options to the default empty array.
return fetchSomeOptions(maxAmount).then(
(newOptions) => dispatch({type: "[FETCH_SOMEARRAY] SUCCESS", newOptions}),
(error) => dispatch({type: "[FETCH_SOMEARRAY] ERROR", newOptions: [], error})
);
}
}
/**
* The Parent Component manages business logic, as well as handling the loading state.
*/
class Parent extends React.Component {
/* ... your business logic comes here ... */
// imagine that isLoading is set to true whenever an API call started and set to false when it returned with a response.
render () {
return this.props.isLoading ? <Spinner /> : <ConnectedChild />
}
}
// map the isLoading property from the Redux Store
function mapParentStateToProps(state) {
return {
isLoading: state.isLoading
}
}
// connect to Redux
export const ConnectedParent = connect(mapParentStateToProps, Parent);
/**
* The Child Component has its own data management.
* When it is mounted (it happens once in a component's lifecycle) it
*/
class Child extends React.Component {
componentDidMount() {
this.props.getSomeOptions(3);
}
render() {
// in this example it doesn't matter what we try to display
// but we could further complicate the situation
// if it would be a form that listens to the user input
// and it would communicate with a server through API calls.
return (
<ul>
{this.props.options.map(item => <li>{item}</li>)}
</ul>
)
}
}
// map the options property from the Redux Store
function mapChildStateToProps(state) {
return {
options: state.options
}
}
// map the Redux getSomeOptions action creator function to getSomeOptions property
mapChildDispatchToProps = {
getSomeOptions
}
// connect to Redux
export const ConnectedChild = connect(mapChildStateToProps, mapChildDispatchToProps, Child);