Why is React component rerendering when props has not changed?
Asked Answered
C

1

8

I have built an app on ReactJS 16.8.5 and React-Redux 3.7.2. When the app loads the app mounts, initial store is set and database subscriptions are set up against a Firebase Realtime Database. The app contains a sidebar, header and content section. By profiling the app using React Developer Tools I can see that the Sidebar is being rendered several times - triggering rerender of child components. I have implemented React.memo to avoid rerendring when props change. From what I can see the props does not change, but the Sidebar still rerenders, which confuses me.

app.js

//Imports etc...
const jsx = (
  <React.StrictMode>
    <Provider store={store}>
      <AppRouter />
    </Provider>
  </React.StrictMode>
)

let hasRendered = false
const renderApp = () => {
  if (!hasRendered) { //make sure app only renders one time
    ReactDOM.render(jsx, document.getElementById('app'))
    hasRendered = true
  }
}

firebase.auth().onAuthStateChanged((user) => {
  if (user) {
    // Set initial store and db subscriptions
    renderApp()
  }
})

AppRouter.js

//Imports etc...
const AppRouter = ({}) => {
  //...
  return (
    <React.Fragment>
      //uses Router instead of BrowserRouter to use our own history and not the built in one
      <Router history={history}>    
        <div className="myApp">
          <Route path="">
            <Sidebar />
          </Route>
          //More routes here...
        </div>
      </Router>
    </React.Fragment>
  )
}
//...
export default connect(mapStateToProps, mapDispatchToProps)(AppRouter)

Sidebar.js

//Imports etc...
export const Sidebar = (props) => {
  const onRender = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
    if (id !== 'Sidebar') { return }
    console.log('Profile', phase, actualDuration)
  }
  return (
    <Profiler id="Sidebar" onRender={onRender}>
      <React.Fragment>
        {/* Contents of Sidebar */}
      </React.Fragment>
    </Profiler>
}

const mapStateToProps = (state) => {
  console.log('Sidebar mapStateToProps')
  return {
    //...
  }
}
const areEqual = (prevProps, nextProps) => {
  const areStatesEqual = _.isEqual(prevProps, nextProps)
  console.log('Profile Sidebar isEqual', areStatesEqual)
  return areStatesEqual
}
export default React.memo(connect(mapStateToProps, mapDispatchToProps)(Sidebar),areEqual)

Console output

Sidebar mapStateToProps 2 
Profile Sidebar mount 225 
Sidebar mapStateToProps 
Profile Sidebar isEqual true 
Sidebar mapStateToProps 
Profile Sidebar update 123 
Sidebar mapStateToProps 2 
Profile Sidebar update 21 
Sidebar mapStateToProps 
Profile Sidebar update 126 
Sidebar mapStateToProps 
Profile Sidebar update 166 
Sidebar mapStateToProps 
Profile Sidebar update 99 
Sidebar mapStateToProps 
Sidebar mapStateToProps 
Sidebar mapStateToProps 
Sidebar mapStateToProps
Sidebar mapStateToProps 
Sidebar mapStateToProps 
Profile Sidebar update 110 
Sidebar mapStateToProps 
Sidebar mapStateToProps 
Sidebar mapStateToProps 
Profile Sidebar update 4

Why is the Sidebar rerendering eight times when the props has not changed? One rerender would be expected?

Kind regards /K

Celebrity answered 19/4, 2020 at 9:10 Comment(7)
Your React.memo's second argument returns the opposite value. It should return whether a re-render will have the same resultRimma
Thanks @Rimma for pointing out the logical error! I have updated the code example above with the results after changing the return value from the React.memo function! /KCelebrity
And didn't it solve the issue? :(Rimma
No, sorry! :( The result is exactly the same. Which confuses me a little. Yes - I have double checked my code. ;) /KCelebrity
Put a console log in mapStateToProps, I suspect there is a state change that causes mapStateToProps to return a new reference because you didn't memoize it (like using reselect). So if anything in redux state changes and you have const mapStateToProps=state=>({new:'reference'}) it'll cause the connected component to re render.Symmetrical
Hi @HMR. Thank you for the comment! :) Yes - mapStateToProps is called quite a few times. See updated console.log() I do not fully understand how to fix this problem - is it a problem? /KCelebrity
memo doesn't work the way you think it does and is useless they way you use it. The component returned by react-redux connect is a pure component given that the props passed by parent don't change and what mapStateToProps returns doesn't change whenever the state changes it wont re render. Props don't change because parent doesn't re render but I suspect mapStateToProps does something like this: mapState=state=({val:state.val}) and because {val} does not equal {val} (mapState returns new reference every time state changes) it'll re render the connected component.Symmetrical
S
5

As commented; when mapStateToProps returns a new object it will re render the connected component even if no relevant values change.

This is because {} !== {}, an object with same props and values does not equal another object with same props and values because React compares object reference and not the values of the object. That is why you can't change state by mutating it. Mutating changes the values in the object but not the reference to the object.

Your mapStateToProps has to return a new reference at the 2nd level for it to re render with the same values, so {val:1} won't re render but {something:{val:1}} will.

The code below shows how not memoizing the result of mapStateToProps can cause re renders:

const { Provider, connect, useDispatch } = ReactRedux;
const { createStore } = Redux;
const { createSelector } = Reselect;
const { useRef, useEffect, memo } = React;

const state = { val: 1 };
//returning a new state every action but no values
//  have been changed
const reducer = () => ({ ...state });
const store = createStore(
  reducer,
  { ...state },
  window.__REDUX_DEVTOOLS_EXTENSION__ &&
    window.__REDUX_DEVTOOLS_EXTENSION__()
);
const Component = (props) => {
  const rendered = useRef(0);
  rendered.current++;
  return (
    <div>
      <div>rendered:{rendered.current} times</div>
      props:<pre>{JSON.stringify(props)}</pre>
    </div>
  );
};
const selectVal = (state) => state.val;
const selectMapStateToProps = createSelector(
  selectVal,
  //will only re create this object when val changes
  (val) => console.log('val changed') || { mem: { val } }
);
const memoizedMapStateToProps = selectMapStateToProps;
const mapStateToProps = ({ val }) =>
  ({ nonMem: { val } }); //re creates props.nonMem every time
const MemoizedConnected = connect(memoizedMapStateToProps)(
  Component
);
//this mapStateToProps will create a props of {val:1}
//  pure components (returned by connect) will compare each property
//  of the prop object and not the props as a whole. Since props.val
//  never changed between renders it won't re render
const OneLevelConnect = connect(({ val }) => ({ val }))(
  Component
);
const Connected = connect(mapStateToProps)(Component);
const Pure = memo(function Pure() {
  //props never change so this will only be rendered once
  console.log('props never change so wont re render Pure');
  return (
    <div>
      <Connected />
      <MemoizedConnected />
      <OneLevelConnect />
    </div>
  );
});
const App = () => {
  const dispatch = useDispatch();
  useEffect(
    //dispatch an action every second, this will create a new
    // state ref but state.val never changes
    () => {
      setInterval(() => dispatch({ type: 88 }), 1000);
    },
    [dispatch] //dispatch never changes but linting tools don't know that
  );
  return <Pure />;
};

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>


<div id="root"></div>

The mapStateToProps function can also be optimised more by passing a function that returns a function. This way you can create a memoized selector when the component mounts. This can be used in list items (see code below).

const { useRef, useEffect } = React;
const {
  Provider,
  useDispatch,
  useSelector,
  connect,
} = ReactRedux;
const { createStore } = Redux;
const { createSelector } = Reselect;
const state = {
  data: [
    {
      id: 1,
      firstName: 'Ben',
      lastName: 'Token',
    },
    {
      id: 2,
      firstName: 'Susan',
      lastName: 'Smith',
    },
  ],
};
//returning a new state every action but no values
//  have been changed
const reducer = () => ({ ...state });
const store = createStore(
  reducer,
  { ...state },
  window.__REDUX_DEVTOOLS_EXTENSION__ &&
    window.__REDUX_DEVTOOLS_EXTENSION__()
);
//selectors
const selectData = (state) => state.data;
const selectPerson = createSelector(
  selectData,
  (_, id) => id, //pass second argument to select person by id
  (people, _id) => people.find(({ id }) => id === _id)
);
//function that will create props for person component
//  from person out of state
const asPersonProps = (person) => ({
  person: {
    fullName: person.firstName + ' ' + person.lastName,
  },
});
//in ConnectedPerson all components share this selector
const selectPersonProps = createSelector(
  (state, { id }) => selectPerson(state, id),
  asPersonProps
);
//in OptimizedConnectedPerson each component has it's own
//  selector
const createSelectPersonProps = () =>
  createSelector(
    (state, { id }) => selectPerson(state, id),
    asPersonProps
  );

const Person = (props) => {
  const rendered = useRef(0);
  rendered.current++;
  return (
    <li>
      <div>rendered:{rendered.current} times</div>
      props:<pre>{JSON.stringify(props)}</pre>
    </li>
  );
};
//optimized mapStateToProps
const mapPersonStateToProps = createSelectPersonProps;
const OptimizedConnectedPerson = connect(
  mapPersonStateToProps
)(Person);
const ConnectedPerson = connect(selectPersonProps)(Person);
const App = () => {
  const dispatch = useDispatch();
  const people = useSelector(selectData);
  const rendered = useRef(0);
  rendered.current++;
  useEffect(
    //dispatch an action every second, this will create a new
    // state ref but state.val never changes
    () => {
      setInterval(() => dispatch({ type: 88 }), 1000);
    },
    [dispatch] //dispatch never changes but linting tools don't know that
  );
  return (
    <div>
      <h2>app rendered {rendered.current} times</h2>
      <h3>Connected person (will re render)</h3>
      <ul>
        {people.map(({ id }) => (
          <ConnectedPerson key={id} id={id} />
        ))}
      </ul>
      <h3>
        Optimized Connected person (will not re render)
      </h3>
      <ul>
        {people.map(({ id }) => (
          <OptimizedConnectedPerson key={id} id={id} />
        ))}
      </ul>
    </div>
  );
};

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>


<div id="root"></div>
Symmetrical answered 19/4, 2020 at 13:0 Comment(2)
Wow! many thanks @Symmetrical ! Will try out createSelector to memoize the result of mapStateToProps!! /KCelebrity
Thanks @Symmetrical for a great answer! I have been reading up and fiddling around with Reselect - and have had some success. But my component is still re-rendering much more than desired. I must be missing something? I have posted a new question here #61405982 Kind regards /KCelebrity

© 2022 - 2024 — McMap. All rights reserved.