Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React prevent remounting components passed from props

When using React with React Router I run in some mounting issues. This might not even be a problem with React Router itself. I want to pass some additional data along with the child routes. This seems to be working, however the changes on the main page trigger grandchildren to be remounted every time the state is changed.

Why is this and why doe this only happen to grandchildren an not just the children ?

Code example:

import React, { useEffect, useState } from 'react';
import { Route, Switch,  BrowserRouter as Router, Redirect } from 'react-router-dom';

const MainPage = ({ ChildRoutes }) => {
  const [foo, setFoo] = useState(0);
  const [data, setData] = useState(0);
  const incrementFoo = () => setFoo(prev => prev + 1);

  useEffect(() =>{
    console.log("mount main")
  },[]);

  useEffect(() =>{
    setData(foo * 2)
  },[foo]);

  return (
    <div>
      <h1>Main Page</h1>      
      <p>data: {data}</p>
      <button onClick={incrementFoo}>Increment foo {foo}</button>
      <ChildRoutes foo={foo} />
    </div>
  );
};

const SecondPage = ({ ChildRoutes, foo }) => {
  const [bar, setBar] = useState(0);
  const incrementBar = () => setBar(prev => prev + 1);

  useEffect(() =>{
    console.log("mount second")
  },[]);

  return (
    <div>
      <h2>Second Page</h2>       
      <button onClick={incrementBar}>Increment bar</button>
      <ChildRoutes foo={foo} bar={bar} />
    </div>
  );
};

const ThirdPage = ({ foo, bar }) => {  
  useEffect(() =>{
    console.log("mount third")
  },[]);

  return (
    <div>
      <h3>Third Page</h3>
      <p>foo: {foo}</p>
      <p>bar: {bar}</p>
    </div>
  );
};

const routingConfig = [{
  path: '/main',
  component: MainPage,
  routes: [
    {
      path: '/main/second',
      component: SecondPage,
      routes: [
        {
          path: '/main/second/third',
          component: ThirdPage
        },
      ]
    }
  ]
}];

const Routing = ({ routes: passedRoutes, ...rest }) => {
  if (!passedRoutes) return null;

  return (
    <Switch>
      {passedRoutes.map(({ routes, component: Component, ...route }) => {
        return (
          <Route key={route.path} {...route}>
            <Component {...rest} ChildRoutes={props => <Routing routes={routes} {...props}/>}/>
          </Route>
        );
      })}
    </Switch>
  );
};

export const App = () => {
  return(
    <Router>
      <Routing routes={routingConfig}/>
      <Route exact path="/">
        <Redirect to="/main/second/third" /> 
      </Route>
    </Router>
  )
};


export default App;

Every individual state change in the MainPage causes ThirdPage to be remounted.

I couldn't create a snippet with StackOverflow because of the React Router. So here is a codesandbox with the exact same code: https://codesandbox.io/s/summer-mountain-unpvr?file=/src/App.js

Expected behavior is for every page to only trigger the mounting once. I know I can probably fix this by using Redux or React.Context, but for now I would like to know what causes this behavior and if it can be avoided.

==========================

Update: With React.Context it is working, but I am wondering if this can be done without it?

Working piece:

const ChildRouteContext = React.createContext();

const ChildRoutesWrapper = props => {
  return (
    <ChildRouteContext.Consumer>
      { routes => <Routing routes={routes} {...props} /> }
    </ChildRouteContext.Consumer>    
  );
}

const Routing = ({ routes: passedRoutes, ...rest }) => {
  if (!passedRoutes) return null;

  return (
    <Switch>
      {passedRoutes.map(({ routes, component: Component, ...route }) => {
        return (
          <Route key={route.path} {...route}>
            <ChildRouteContext.Provider value={routes}>
              <Component {...rest} ChildRoutes={ChildRoutesWrapper}/>
            </ChildRouteContext.Provider>
          </Route>
        );
      })}
    </Switch>
  );
};
like image 447
Kevin Avatar asked Oct 09 '20 12:10

Kevin


2 Answers

To understand this issue, I think you might need to know the difference between a React component and a React element and how React reconciliation works.

React component is either a class-based or functional component. You could think of it as a function that will accept some props and eventually return a React element. And you should create a React component only once.

React element on the other hand is an object describing a component instance or DOM node and its desired properties. JSX provide the syntax for creating a React element by its React component: <Component someProps={...} />

At a single point of time, your React app is a tree of React elements. This tree is eventually converted to the actual DOM nodes which is displayed to our screen.

Everytime a state changes, React will build another whole new tree. After that, React need to figure a way to efficiently update DOM nodes based on the difference between the new tree and the last tree. This proccess is called Reconciliation. The diffing algorithm for this process is when comparing two root elements, if those two are:

  • Elements Of Different Types: React will tear down the old tree and build the new tree from scratch // this means re-mount that element (unmount and mount again).
  • DOM Elements Of The Same Type: React keeps the same underlying DOM node, and only updates the changed attributes.
  • Component Elements Of The Same Type: React updates the props of the underlying component instance to match the new element // this means keep the instance (React element) and update the props

That's a brief of the theory, let's get into pratice.

I'll make an analogy: React component is a factory and React element is a product of a particular factory. Factory should be created once.

This line of code, ChildRoutes is a factory and you are creating a new factory everytime the parent of the Component re-renders (due to how Javascript function created):

<Component {...rest} ChildRoutes={props => <Routing routes={routes} {...props}/>}/>

Based on the routingConfig, the MainPage created a factory to create the SecondPage. The SecondPage created a factory to create the ThirdPage. In the MainPage, when there's a state update (ex: foo got incremented):

  1. The MainPage re-renders. It use its SecondPage factory to create a SecondPage product. Since its factory didn't change, the created SecondPage product is later diffed based on "Component Elements Of The Same Type" rule.
  2. The SecondPage re-renders (due to foo props changes). Its ThirdPage factory is created again. So the newly created ThirdPage product is different than the previous ThirdPage product and is later diffed based on "Elements Of Different Types". That is what causing the ThirdPage element to be re-mounted.

To fix this issue, I'm using render props as a way to use the "created-once" factory so that its created products is later diffed by "Component Elements Of The Same Type" rule.

<Component 
    {...rest} 
    renderChildRoutes={(props) => (<Routing routes={routes} {...props} />)}
/>

Here's the working demo: https://codesandbox.io/s/sad-microservice-k5ny0


Reference:

  • React Components, Elements, and Instances
  • Reconciliation
  • Render Props
like image 197
dongnhan Avatar answered Sep 22 '22 01:09

dongnhan


The culprit is this line:

<Component {...rest} ChildRoutes={props => <Routing routes={routes} {...props}/>}/>

More specifically, the ChildRoutes prop. On each render, you are feeding it a brand new functional component, because given:

let a = props => <Routing routes={routes} {...props}/>
let b = props => <Routing routes={routes} {...props}/>

a === b would always end up false, as it's 2 distinct function objects. Since you are giving it a new function object (a new functional component) on every render, it has no choice but to remount the component subtree from this Node, because it's a new component every time.

The solution is to create this functional component once, in advance, outside your render method, like so:

const ChildRoutesWrapper = props => <Routing routes={routes} {...props} />

... and then pass this single functional component:

<Component {...rest} ChildRoutes={ChildRoutesWrapper} />
like image 27
John Weisz Avatar answered Sep 21 '22 01:09

John Weisz