React unmounts and remounts layout components using react-router

Viewed 1717

I have react-router set up and a Switch component that renders some Page components. All my components render the same layout (for now. Later it will be parametric). My page components "inject" content to the layout so I can't have the layout component wrap my outer switch component. It has to be used as a child from within the page components.

// Sample code:

// app.js
const App = () => (
    <LanguageProvider defaultLanguage="el" languages={languages}>
        <PathProvider>
            <MenuProvider menu={appMenu}>
                <TemplateProvider injectedComponents={uiComponentOverrides} toolBar={toolBar}>
                    <Switch>
                        <Redirect exact from="/" to="dashboard" />
                        <Route exact path="/dashboard" component={DashboardIndex} />
                        <Route exact path="/overview" component={OverviewIndex} />
                        <Route exact path="/federation" component={FederationIndex} />
                        <Route exact path="/clubs" component={ClubsIndex} />
                        <Route exact path="/athletes" component={AthletesIndex} />
                        <Route exact path="/athletes/:id" component={AthleteDetails} />
                        <Route exact path="/officials" component={OfficialsIndex} />
                        <Route component={EmptyPage} />
                    </Switch>
                </TemplateProvider>
            </MenuProvider>
        </PathProvider>
    </LanguageProvider>
);


// Sample of a page component
// dashboard-page.js

export const DashboardIndex = () => {
    const { t } = useTranslation();
    return (
        <AppFullPage key="page-component" title={t('Home.Index.WelcomeTitle')}>
            <p>Dashboard content</p>
        </AppFullPage>
    );
};

// The AppFullPage component is the layout component. Content is injected to it through the title, subtitle, toolbar, menu and children props.

Even though the final html render is the same from the layout skeleton (it only differs inside the slot parts of the layout), react unmounts and remounts the whole layout tree.

I can understand react re-rendering the whole tree. React sees a different top component (the page component is different for every location). So react renders the new component and creates a new virtual dom.

What I can't understand is unmounting and remounting of the top html elements since they didn't change. The top div didn't change. The language picker dropdown in my header menu didn't change. The application menu in the left didn't change. Yes, they where rendered from different components but they are the same output.

If react compares the outputs of successive renders and only changes what is needed, then why the components are unmounted and remounted?

This behavior breaks animations and other behavior of components (eg I would like a dropdown in the common layout space to remain open even after a page change, but since it is unmounted and remounted, it loses state).

How can I instruct react to not remount identical components?

Or

How can I achieve my goal of letting the page inject content in the layout?

Is using portals a candidate for a solution?

3 Answers

Can we see your layout component? Are you sure it unmounts/remounts or re-renders?

If component unmounts - there should be some kind of conditional rendering, check that place. If components re-renders then check props to be sure they not changes when they must not.

Move your common layout components outside of the switch. Then use the location to determine your dynamic props.

<AppFullPage>
  <Switch>
    {all routes}
  </Switch>
</AppFullPage>
const AppFullPage = () => {
  const location = useLocation();
  const [title, setTitle] = useState('');
  const { t } = useTranslation();

  useEffect(() => {
    switch(location.pathname) {
      case 'dashboard': {
        setTitle(t('Home.Index.WelcomeTitle'));
      }
      ... the rest of your cases
    }
  }, [location.pathname]);

}

This code is just an example, idk if it will work exactly as is but should give you an idea

The solution I came to use is below. This is a hack workaround to make it possible for nested pages to provide content to the layout in the levels above. I will try to improve it.

What I am doing is creating a context (LayoutContext) that would hold the rendered elements for each slot by slot name. The Layout component holds the state of the context and passes the section setter to the descendands through the context.

I created helper components (LayoutSectionPlaceHolder and LayoutSection) to encapsulate the use of hooks and memoization wherever needed.

Within the Layout component we render a LayoutSectionPlaceHolder element in each place we want content to be injected by the page.

Each page component should return one or more LayoutSection elements and only that. We don't want the page itself to render anything. We only want it to push(inject) content to the layout whenever that content changes. LayoutSection component is responsible for injecting the content provided to it as the children prop, to the Layout.

This solution is a hack because in the first render of the page, all slots are empty (no content has been set yet). And this is because content is provided first time when the page element is mounted, using useLayoutEffect hook, and then whenever the content changes.

That being said, it would be nice if React provided a build-in mechanism of reverse context (from children to parent) as an inversion of control pattern

import React, { createContext, useCallback, useContext, useLayoutEffect, useReducer, useState } from 'react';
import { Route, Switch } from 'react-router';
import { BrowserRouter, Link } from 'react-router-dom';
function App() {
    return (
        <BrowserRouter>
            <Switch>
                <Route exact path='/page1' render={() => <Layout><Page1 /></Layout>} />
                <Route exact path='/page2' render={() => <Layout><Page2 /></Layout>} />
            </Switch>
        </BrowserRouter>
    );
}

const LayoutSlotSetterContext = createContext(() => {});

const useLayoutSlotSetter = () => {
    const { setSection } = useContext(LayoutSlotSetterContext);
    return setSection;
};

const useLayoutSectionUpdater = (slotName, content, slotSetter) => useLayoutEffect(() => slotSetter(slotName, content), [content, slotName, slotSetter]);
const useLayoutSectionContent = (sectionName) => {
    const { sections: { [sectionName]: section } = {} } = useContext(LayoutSlotSetterContext);
    return section;
};

const LayoutSectionPlaceHolder = ({ section }) => {
    const content = useLayoutSectionContent(section);
    return content === undefined ? null : content;
};

const LayoutSection = ({ section, children }) => {
    const slotSetter = useLayoutSlotSetter();
    useLayoutSectionUpdater(section, children, slotSetter);
    return null;
};

const Layout = ({ children }) => {
    useLayoutEffect(() => () => console.log('unmounted: [Layout]'), []);
    const reducer = useCallback((sections, { section, content }) => {
        if (!section) return sections;
        return {
            ...sections,
            [section]: content,
        };
    }, []);
    const [sections, dispatch] = useReducer(reducer, {});
    const setSection = useCallback((section, content) => dispatch({ section, content }), [dispatch]);

    return (
        <LayoutSlotSetterContext.Provider value={{ sections, setSection }}>
            {children}
            <Menu /> {/* <-- this element is the same for every page. It should never unmounted */}
            <Header /> {/* <-- this element is the same for every page. It should never be unmounted */}
            <div>
                <LayoutSectionPlaceHolder section='content' />
            </div>
            <div>
                <LayoutSectionPlaceHolder section='secondsMounted' /> seconds mounted [<LayoutSectionPlaceHolder section='page' />]
            </div>
        </LayoutSlotSetterContext.Provider>
    );
};

const Menu = () => {
    useLayoutEffect(() => () => console.log('unmounted: [Menu]'), []);
    return (
        <ul>
            <li>
                <Link to='/page1'>Page 1</Link>
            </li>
            <li>
                <Link to='/page2'>Page 2</Link>
            </li>
        </ul>
    );
};

const Header = () => {
    useLayoutEffect(() => () => console.log('unmounted: [Header]'), []);
    const counter = useSecondsMounted();
    return <h2>{counter} seconds mounted [header]</h2>;
};

const Page1 = () => {
    const counter = useSecondsMounted();
    return (
        <>
            <LayoutSection section='content'>
                <p>Page 1 content</p>
            </LayoutSection>
            <LayoutSection section='page'>Page 1</LayoutSection>
            <LayoutSection section='secondsMounted'>{counter}</LayoutSection>
        </>
    );
};

const Page2 = () => {
    const counter = useSecondsMounted();
    return (
        <>
            <LayoutSection section='content'>
                <p>Page 2 content</p>
            </LayoutSection>
            <LayoutSection section='page'>Page 2</LayoutSection>
            <LayoutSection section='secondsMounted'>{counter}</LayoutSection>
        </>
    );
};

/** Counts the seconds a component stays mounted */
const useSecondsMounted = () => {
    const [counter, setCounter] = useState(0); // used for illustration purposes. The counter increases +1/sec. It should do that as long as it is visible in the page
    useLayoutEffect(() => {
        const interval = setInterval(() => setCounter((c) => c + 1), [1000]);
        return () => clearInterval(interval);
    }, []);
    return counter;
};

export default App;
Related