React 16.13.1 - child component does not re-render when props change

Viewed 3064

I have a parent component and a child component. The child component initially renders data into a form but upon changing the data, the child component does not update.

Parent Component:

import React from 'react'
import { connect } from 'react-redux'
import styles from '../styles'
import ExpressionsForm from './expressionsForm'

class EditCondition extends React.Component {
  constructor (props) {
    super(props)
    this.state = {
      condition: null
    }

    this.updateExpression = this.updateExpression.bind(this)

    this.changes = false
  }

  componentWillMount () {
    let conditionid = this.props.data.id
    let condition = this.props.conditions.find(c => {
      return (c.id = conditionid)
    })
    this.setState({ condition })
  }

  updateExpression (e) {
    let expressionid = e.currentTarget.dataset.expressionid
    let field = e.currentTarget.dataset.field
    let value = e.target.value
    let condition = this.state.condition
    let expression = condition.expressions[expressionid]
    expression[field] = value
    condition.expressions[expressionid] = expression
    this.changes = true
    this.setState({ condition })
    console.log('updateExpression condition: ', condition)
  }

  render () {
    let condition = this.state.condition
    if (!this.state.condition) {
      return (
        <div>
          The selected condition with ID "{this.props.data.id}" did not load. It
          may not exist. Refresh and try again.
        </div>
      )
    }

    let groupOptions = this.props.gambitGroups.map(g => {
      return (
        <option value={g.id} key={'group' + g.id}>
          {g.name}
        </option>
      )
    })

    console.log('RENDER editCondition: ', condition) // <-- Note: This always logs as expected

    let expressionsJSX = condition.expressions.map((expression, i) => {
      expression.id = i
      console.log('expression: ', expression) // <-- Note: This always logs as expected
      return (
        <ExpressionsForm
          key={'expressionsForm_' + i}
          expression={expression}
          deleteExpression={this.deleteExpression}
          updateExpression={this.updateExpression}
          updateExpressionData={this.updateExpressionData}
        />
      )
    })

    return (
           <table>
             <thead>
               <tr>
                 <th {...styles.modal.tableHeaderLeftAlign}>
                   Device &amp; Data Point
                 </th>
                 <th {...styles.modal.tableHeaderLeftAlign}>Operator</th>
                 <th {...styles.modal.tableHeaderLeftAlign}>Value</th>
                 <th {...styles.modal.tableHeaderLeftAlign}>PlateValue</th>
                 <th {...styles.modal.tableHeaderLeftAlign}>&nbsp;</th>
               </tr>
             </thead>
             <tbody>{expressionsJSX}</tbody>
           </table>
            
    )
  }
}

export default connect(
  (state, ownProps) => ({
    user: state.user,
    users: state.users,
    gambitGroups: state.gambitGroups,
    // deviceGroups: state.deviceGroups,
    conditions: state.conditions,
    reactions: state.reactions,
    setEditMode: ownProps.setEditMode,
    navByName: ownProps.navByName
  }),
  dispatch => ({
    addImage: file => dispatch({ type: 'UPDATE_CONDITION_LOGO', file }),
    updateCondition: condition =>
      dispatch({ type: 'UPDATE_CONDITION', condition })
  })
)(EditCondition)

And Child Component:

import React from 'react'
import { connect } from 'react-redux'
import styles from '../styles'

class ExpressionsForm extends React.Component {
  constructor (props) {
    super(props)
    this.state = {}

    this.updateExpression = this.updateExpression.bind(this)
  }

  updateExpression (e) {
    this.props.updateExpression(e)
  }

  render () {
    let expression = this.props.expression
    console.log('expression: ', expression) // Note: logs initial render only.
    let data = expression.data
    let deviceId = data.deviceId
    let dataPointIndex = data.dataPointIndex
    let operator = expression.operator
    let plateValue = expression.plateValue
    let value = expression.value

    console.log('RENDER expressionForm: ', expression) // Note: logs initial render only

    let deviceOptions = this.props.devices.map((device, i) => {
      return (
        <option value={device.id} key={'device_' + i}>
          {device.userAssignedName}
        </option>
      )
    })

    let dataPointOptions = this.props.devices[0].inputs.map((input, i) => {
      return (
        <option value={input.id} key={'input_' + i}>
          {input.name} currentValue: {input.value}
        </option>
      )
    })

    let operatorOptions = ['==', '!=', '<=', '>=', '<', '>'].map(
      (operator, i) => {
        return (
          <option value={operator} key={'operator_' + i}>
            {operator}
          </option>
        )
      }
    )

    return (
      <tr>
        <td>
          <select
            {...styles.modal.inputSexy}
            style={{ marginBottom: '20px' }}
            data-field='deviceid'
            data-expressionid={expression.id}
            value={deviceId}
            onChange={this.updateExpressionData}
          >
            <option value=''></option>
            {deviceOptions}
          </select>
          <select
            {...styles.modal.inputSexy}
            data-field='dataPointIndex'
            data-expressionid={expression.id}
            value={dataPointIndex}
            onChange={this.updateExpressionData}
          >
            <option value=''></option>
            {dataPointOptions}
          </select>
        </td>
        <td>
          <select
            {...styles.modal.inputSexy}
            style={{ width: '75px' }}
            data-field='operator'
            data-expressionid={expression.id}
            value={operator}
            onChange={this.updateExpression}
          >
            <option value=''></option>
            {operatorOptions}
          </select>
        </td>
        <td>
          <input
            {...styles.modal.inputSexy}
            style={{ width: '50px' }}
            data-field='value'
            data-expressionid={expression.id}
            value={value}
            onChange={this.updateExpression}
          />
        </td>
        <td>
          <input
            {...styles.modal.inputSexy}
            style={{ width: '88px' }}
            data-expressionid={expression.id}
            data-field='plateValue'
            value={plateValue}
            onChange={this.updateExpression}
          />
        </td>
        <td>
          <i className='fa fa-close'
            data-expressionid={expression.id}
            onClick={this.deleteExpression}
          ></i>
          &nbsp;
        </td>
      </tr>
    )
  }
}

export default connect(
  (state, ownProps) => ({
    user: state.user,
    users: state.users,
    devices: state.devices,
    gambitGroups: state.gambitGroups,
    // deviceGroups: state.deviceGroups,
    conditions: state.conditions,
    reactions: state.reactions,
    setEditMode: ownProps.setEditMode,
    navByName: ownProps.navByName
  }),
  dispatch => ({
    addImage: file => dispatch({ type: 'UPDATE_XXX', file })
  })
)(ExpressionsForm)

I have an array of objects in the redux store called Conditions. The parent component gets an ID of one of these conditions, finds the correct condition, and loads it into state via componentWillMount to be modified by the user. The condition has an array of objects on it called expressions. Each of these expressions are passed to the child component called ExpressionsForm.

so we loop over the expressions via the map function and return the resulting JSX as expressionsJSX.

let expressionsJSX = condition.expressions.map((expression, i) => {
          expression.id = i
          console.log('expression: ', expression) // <-- Note: This always logs as expected
          return (
            <ExpressionsForm
              key={'expressionsForm_' + i}
              expression={expression}
              deleteExpression={this.deleteExpression}
              updateExpression={this.updateExpression}
              updateExpressionData={this.updateExpressionData}
            />
          )
        })

Note that the has the expression passed to it expression={expression}

And in the child component's render you see

    let expression = this.props.expression
    console.log('expression: ', expression) // Note: logs initial render only.

Since this is a prop, whether it's being console.log'd or rendered into some JSX doesn't matter - when the prop changes the changes should also be re-rendered. BUT it's not doing it in this case. Why?

For example, I have 1 expression saved on 1 condition. It renders, I click into the plateValue input field of the expression - which contains a 5 by default - and attempt to add a 6 after the 5. When the parent component updates state an re-renders I see the in the console.log's that the expression's plateValue field now contains a '56'...it just doesn't render in the child component....!?

Here is an example console.log

Initial Render:

RENDER editCondition: {id: "1", group: 1, name: "Temperature >= 75F", meta: "If >= 75F in greenhouse turn on AC until 5 degrees cooler than 75F", expressions: Array(1)} editCondition.jsx:191 expression: {data: {…}, operator: ">=", value: "75", plateValue: "5", id: 0} expressionsForm.jsx:39 RENDER expressionForm: {data: {…}, operator: ">=", value: "75", plateValue: "5", id: 0}

Click into plateValue field and add a '6', parent rerenders...and:

editCondition.jsx:188 RENDER editCondition: {id: "1", group: 1, name: "Temperature >= 75F", meta: "If >= 75F in greenhouse turn on AC until 5 degrees cooler than 75F", expressions: Array(1)} editCondition.jsx:191 expression: {data: {…}, operator: ">=", value: "75", plateValue: "56", id: 0} editCondition.jsx:153 STATE SET! updateExpression condition: {id: "1", group: 1, name: "Temperature >= 75F", meta: "If >= 75F in greenhouse turn on AC until 5 degrees cooler than 75F", expressions: Array(1)}

I see a 'plateValue: "56"' in there. So why isn't it rerendering in the child component? So confused.

I've tried componentWillReceiveProps, componentWillUpdate, et al. I can't even get these to fire off a console.log.

Something is going on that I can't figure out. I've been doing React for a long time and I'm stumped. That doesn't happen very often anymore.

Thanks in advance for your help

PS I did look at getDerivedStateFromProps - It's great that the documentation provides examples, but they don't explain what the props and state parameters actually are. The docs suck. Their explanation sucks. Their example doesn't illustrate what it actually does. I only ever use componentWillReceiveProps to know when a prop has changed, and then update state or whatever. getDerivedStateFromProps just confuses me. None the less I played around with it and couldn't get it to work either.

Visual Example

1 Answers

It looks like the same expression object is being passed in all the time.

React checks the props that a component receives for changes when deciding to render. It finds that none of the props items have changed, they are all the same objects as before, and concludes that the child component does not need to be rerendered. It will not do a deep inspection of all properties of each prop.

This also explains why a rerender can be forced by making a copy of the expression object. The copy is always a new object, thus causing a rerender, regardless if any of its content have changed or not.

You could avoid this situation as you do already, by making a copy, or by dissecting the expression object into its properties and then feeding each of those as separate props into the child.

As a final note, a copy can also be made by passing it in as expression={{...expression}}.

Related