1

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?

Ragav Y
  • 1,662
  • 1
  • 18
  • 32
  • 1
    Are you sure it gets called from `componentDidMount()`? What happens if you console.log something random at the top of `componentDidMount()`? Does it print more than once? – Chris Oct 27 '17 at 11:55
  • Yes, I console.logged "this.props.programId and this.props.programContactData.length" from within the if statement and I kept getting eg. "66 and 0" over and over on a loop. – Ragav Y Oct 27 '17 at 12:04
  • Okay, so the problem is probably that your component re-mounts itself constantly. – Chris Oct 27 '17 at 12:06
  • That would explain it, so how do I overcome it? I don't understand why it must remount in the first place. It doesn't do this when I remove the if statement, which is why I was wondering if the conditional statements within the CDM is an issue. – Ragav Y Oct 27 '17 at 12:07
  • Oh? So if you add the `if` statements you get the loop but not without? Do me a favor and do `console.log("foo")` between the `componentDidMount()` and the `if`. Then also `console.log("bar")` inside the `if`. How many `foo` and how many `bar` are logged? – Chris Oct 27 '17 at 12:10
  • @chris, figured it out, see my comment in yuantonito's answer. – Ragav Y Oct 28 '17 at 05:23

2 Answers2

3

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:

  1. Are my data still valid ?
  2. 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.

yuantonito
  • 1,274
  • 8
  • 17
  • Thank you @yuantonito for the time and effort in writing such a detailed answer. I was reading through your answer and suddenly something struck and I figured out the issue. The problem is with the fact that the program from which I was trying to access the form has no contacts registered in it!!! So the fetching does happen and the programContacts are getting updated in the redux. But the conditional that I used wasn't ready to handle that case. And the form works as intended from a program that has more than 0 contacts. Now that I know what the issue is, I can work out the solution. – Ragav Y Oct 28 '17 at 05:13
  • I'm selecting this as the answer because it offers insight into the proper approach to use in a case like mine. – Ragav Y Oct 28 '17 at 05:22
0

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);
  }
}
krmzv
  • 387
  • 3
  • 14