I am working on a react app using redux and sagas connected to an API.
There's a form component that has two dropdown fields: a Program and a Contact field. The way the form is designed to work is, when the user selects a program, the form uses the programId to fetch all the contacts that have registered for that program. These contacts are then populated as the options for the contact dropdown field. This works and I've implemented it using the componentWillReceiveProps, like this:-
componentWillReceiveProps(nextProps) {
if (nextProps.programId !== this.props.programId) {
this.props.fetchProgramContacts(nextProps.programId);
}
}
Now I'm trying to have an additional feature that autopopulates the form with the programId when this form is accessed from the program's profile page. In this case, since the programId is preloaded into the formData even before the component mounts, the componentWillReceiveProps is not triggered as there is no change in the prop. So I decided to have the programContacts fetching in the componentDidMount lifecycle method, like this:-
componentDidMount() {
if (this.props.programId !== '' && !this.props.programContactData.length) {
this.props.fetchProgramContacts(this.props.programId);
}
}
The logic is that the fetch request must be made only when the programId is not empty and the programContacts are empty. But this goes on an endless loop of fetching.
I discovered that the if statement gets executed over and over because the expressions in the body of the if statement is executed again by the componentDidMount even before the previous fetch request gets returned with the results. And because one of the conditions is to check whether the length of the results array is nonempty, the if statement returns true and so the loop goes on without letting the previous requests reach completion.
What I don't understand is why the if statement must be executed repeatedly. Shouldn't it exit the lifecycle method once the if statement is executed once?
I know that maybe it is possible to use some kind of a timeout method to get this to work, but that is not a robust enough technique for me to rely upon.
Is there a best practice to accomplish this?
Also, is there any recommendation to not use if conditionals within the componentDidMount method?
In the React lifecycle, componentDidMount()
is only triggered once.
Make sure the call is made from the componentDidMount
and not componentWillReceiveProps
.
If the call trully comes from componentDidMount
, it means you component is recreated every time.
It can be checked by adding a console.log
in the constructor
of your component.
In any case, you should prefer using the isFetching
and didInvalidate
of redux to handle data fetching / refetching.
You can see one of my detailed answer of how it works in another question: React-Redux state in the component differs from the state in the store
If I focus on your usecase, you can see below an application of the isFetching
and didInvalidate
concept.
1. Components
Take a look at the actions and reducers but the trick with redux is to play with the isFetching
and didInvalidate
props.
The only two questions when you want to fetch your data will be:
- Are my data still valid ?
- Am I currently fetching data ?
You can see below that whenever you select a program you will invalidate the fetched data in order to fetch again with the new programId as filter.
Note: You should use connect
of redux
to pass the actions and reducers to your components of course !
MainView.js
class MainView extends React.Component {
return (
<div>
<ProgramDropdown />
<ContactDropdown />
</div>
);
}
ProgramDropdown.js
class ProgramDropdown extends React.Component {
componentDidMount() {
if (this.props.programs.didInvalidate && !this.props.programs.isFetching) {
this.props.actions.readPrograms();
}
}
render() {
const {
isFetching,
didInvalidate,
data,
} = this.props;
if (isFetching || (didInvalidate && !isFetching)) {
return <select />
}
return (
<select>
{data.map(entry => (
<option onClick={() => this.props.actions.setProgram(entry.id)}>
{entry.value}
</option>
))}
</select>
);
}
}
ContactDropdown.js
class ContactDropdown extends React.Component {
componentDidMount() {
if (this.props.programs.selectedProgram &&
this.props.contacts.didInvalidate && !this.props.contacts.isFetching) {
this.props.actions.readContacts(this.props.programs.selectedProgram);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.programs.selectedProgram &&
nextProps.contacts.didInvalidate && !nextProps.contacts.isFetching) {
nextProps.actions.readContacts(nextProps.programs.selectedProgram);
}
}
render() {
const {
isFetching,
didInvalidate,
data,
} = this.props;
if (isFetching || (didInvalidate && !isFetching)) {
return <select />
}
return (
<select>
{data.map(entry => (
<option onClick={() => this.props.actions.setContact(entry.id)}>
{entry.value}
</option>
))}
</select>
);
}
}
2. Contact Actions
I'm going to focus only on the contact actions as the program one is nearly the same.
export function readContacts(programId) {
return (dispatch, state) => {
dispatch({ type: 'READ_CONTACTS' });
fetch({ }) // Insert programId in your parameter
.then((response) => dispatch(setContacts(response.data)))
.catch((error) => dispatch(addContactError(error)));
};
}
export function selectContact(id) {
return {
type: 'SELECT_CONTACT',
id,
};
}
export function setContacts(data) {
return {
type: 'SET_CONTACTS',
data,
};
}
export function addContactError(error) {
return {
type: 'ADD_CONTACT_ERROR',
error,
};
}
3. Contact Reducers
import { combineReducers } from 'redux';
export default combineReducers({
didInvalidate,
isFetching,
data,
selectedItem,
errors,
});
function didInvalidate(state = true, action) {
switch (action.type) {
case 'SET_PROGRAM': // !!! THIS IS THE TRICK WHEN YOU SELECT ANOTHER PROGRAM, YOU INVALIDATE THE FETCHED DATA !!!
case 'INVALIDATE_CONTACT':
return true;
case 'SET_CONTACTS':
return false;
default:
return state;
}
}
function isFetching(state = false, action) {
switch (action.type) {
case 'READ_CONTACTS':
return true;
case 'SET_CONTACTS':
return false;
default:
return state;
}
}
function data(state = {}, action) {
switch (action.type) {
case 'SET_CONTACTS':
return action.data;
default:
return state;
}
}
function selectedItem(state = null, action) {
switch (action.type) {
case 'SELECT_CONTACT':
return action.id;
case 'READ_CONTACTS':
case 'SET_CONTACTS':
return null;
default:
return state;
}
}
function errors(state = [], action) {
switch (action.type) {
case 'ADD_CONTACT_ERROR':
return [
...state,
action.error,
];
case 'SET_CONTACTS':
return state.length > 0 ? [] : state;
default:
return state;
}
}
Hope it helps.
The actual problem is in the componentWillReceiveProps method itself, the infinite loop is created here.
You are checking if current and next programId will not match, and then trigger an action that will make current and next programId not match again. With given action fetchProgramContacts you are somehow mutating the programId. Check your reducers.
One of the solution to this is to have reqFinished (true/false) in your reducer, and then you should do something like this:
componentWillReceiveProps(nextProps){
if(nextProps.reqFinished){
this.props.fetchProgramContacts(nextProps.programId);
}
}