2

I have an application with nested routes and am hoping to use React Transition Group to transition between the routes, both top level and nested.

My challenge is that I have a context provider wrapping the nested routes. That context provider is unmounting when the page transitions between nested routes, causing the provider to lose state. Without the transitions, the context provider does not unmount and thus state is preserved.

I've implemented a simplified example in a code sandbox: https://codesandbox.io/s/elegant-star-lp7yjx?file=/src/App.js

App

import React from "react";
import {
  Navigate,
  Route,
  Routes,
  NavLink,
  useLocation
} from "react-router-dom";
import "./styles.css";
import { SwitchTransition, CSSTransition } from "react-transition-group";

const SubPage1 = () => {
  return (
    <div>
      <h1>SubPage1</h1>
      <NavLink to="/2">Go to SubPage2</NavLink>
    </div>
  );
};

const SubPage2 = () => {
  return (
    <div>
      <h1>SubPage2</h1>
      <NavLink to="/1">Go to SubPage1</NavLink>
    </div>
  );
};

const Page1 = () => {
  const location = useLocation();
  return (
    <MyContextProvider>
      <h1>Page1</h1>
      <NavLink to="/2">Go to Page2</NavLink>
      <Routes location={location}>
        <Route path="/1" element={<SubPage1 />} />
        <Route path="/2" element={<SubPage2 />} />
        <Route path="*" element={<Navigate to="/1" />} />
      </Routes>
    </MyContextProvider>
  );
};

const Page2 = () => {
  return (
    <div>
      <h1>Page2</h1>
      <NavLink to="/1">Go to Page1</NavLink>
    </div>
  );
};

const MyContext = React.createContext();
const MyContextProvider = ({ children }) => {
  React.useEffect(() => console.log("Context mounting"), []);
  return <MyContext.Provider>{children}</MyContext.Provider>;
};

export default function App() {
  const location = useLocation();
  return (
    <SwitchTransition>
      <CSSTransition
        key={location.key}
        classNames="right-to-left"
        timeout={200}
      >
        <Routes>
          <Route path="/1" element={<Page1 />} />
          <Route path="/2" element={<Page2 />} />
          <Route path="*" element={<Navigate to="/1" />} />
        </Routes>
      </CSSTransition>
    </SwitchTransition>
  );
}

index.js

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";

import App from "./App";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <MemoryRouter>
      <App />
    </MemoryRouter>
  </StrictMode>
);

Am I doing something wrong or is this just not supported?

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
jdimmerman
  • 124
  • 4
  • 15

1 Answers1

1

Issues

The root routes are unable to render the descendent routes because they are missing the trailing "*" wildcard matcher on their paths.

<Routes>
  <Route path="/1" element={<Page1 />} /> // only match "/1" exactly!!
  <Route path="/2" element={<Page2 />} /> // only match "/2" exactly!!
  <Route path="*" element={<Navigate to="/1" />} />
</Routes>

There's a malformed redirect in the Page1 component rendered on "/1"that is simply redirecting to"/1"creating a render loop. Link targets starting with"/"are ***absolute*** paths. The redirect should redirect to the descendent"/1/1"` route.

const Page1 = () => {
  const location = useLocation();
  return (
    <MyContextProvider>
      <h1>Page1</h1>
      <NavLink to="/2">Go to Page2</NavLink>
      <Routes location={location}>
        <Route path="/1" element={<SubPage1 />} />
        <Route path="/2" element={<SubPage2 />} />
        <Route
          path="*"
          element={<Navigate to="/1" />} // redirects to "/1" instead of "/1/1"
        />
      </Routes>
    </MyContextProvider>
  );
};

Solution

Add the missing wildcard matcher on the root routes to allow descendent route matching.

<Routes>
  <Route path="/1/*" element={<Page1 />} />
  <Route path="/2/*" element={<Page2 />} />
  <Route path="*" element={<Navigate to="/1" />} />
</Routes>

Redirect to the correct descendent route in Page1. Use either relative target path "1" (or "./1") or absolute target path "/1/1".

const Page1 = () => {
  const location = useLocation();
  return (
    <MyContextProvider>
      <h1>Page1</h1>
      <NavLink to="/2">Go to Page2</NavLink>
      <Routes location={location}>
        <Route path="/1" element={<SubPage1 />} />
        <Route path="/2" element={<SubPage2 />} />
        <Route path="*" element={<Navigate to="1" />} />
      </Routes>
    </MyContextProvider>
  );
};

Edit context-provider-is-unmounting-when-using-react-transition-group-with-nested-rou

Issue

The MyContextProvider is remounted even when descendent routes change.

This is caused by the root CSSTransition component using a React key coupled to the current location's key. The key changes when the descendent route changes, and when React keys change then that entire sub-ReactTree is remounted.

function App() {
  const location = useLocation();
  return (
    <SwitchTransition>
      <CSSTransition
        key={location.key} // <-- React key change remounts subtree
        classNames="right-to-left"
        timeout={200}
      >
        <Routes>
          <Route path="/1/*" element={<Page1 />} />
          <Route path="/2/*" element={<Page2 />} />
          <Route path="*" element={<Navigate to="/1" />} />
        </Routes>
      </CSSTransition>
    </SwitchTransition>
  );
}

A solution for this is to promote the MyContextProvider higher in the ReactTree such that it remains mounted even while routes change. Wrap the SwitchTransition component with the MyContextProvider and remove MyContextProvider from the Page1 component.

function App() {
  const location = useLocation();
  return (
    <MyContextProvider>
      <SwitchTransition>
        <CSSTransition
          key={location.key}
          classNames="right-to-left"
          timeout={200}
        >
          <Routes>
            <Route path="/1/*" element={<Page1 />} />
            <Route path="/2/*" element={<Page2 />} />
            <Route path="*" element={<Navigate to="/1" />} />
          </Routes>
        </CSSTransition>
      </SwitchTransition>
    </MyContextProvider>
  );
}

Page1

const Page1 = () => {
  const location = useLocation();
  return (
    <>
      <h1>Page1</h1>
      <NavLink to="/2">Go to Page2</NavLink>
      <Routes location={location}>
        <Route path="/1" element={<SubPage1 />} />
        <Route path="/2" element={<SubPage2 />} />
        <Route path="*" element={<Navigate to="1" />} />
      </Routes>
    </>
  );
};

Edit context-provider-is-unmounting-when-using-react-transition-group-with-nested-rou

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Thanks Drew! You are right, I had a pretty bad set of bugs in my code sandbox. Unfortunately that still does not solve the issue I'm running into in that the context provider is remounted. I've updated my code sandbox accordingly to show state getting reset more clearly. https://codesandbox.io/s/elegant-star-lp7yjx?file=/src/App.js If you comment out the SwitchTransition and CSSTransition elements, you will not encounter the issue (and of course have no animations) – jdimmerman Nov 04 '22 at 16:48
  • @jdimmerman The sandbox you linked still render loops. The provider was "remounting" because the code is render looping to the route rendering the provider. The code in my answer resolves this by fixing the render looping issue. – Drew Reese Nov 04 '22 at 16:51
  • @jdimmerman You are also rendering the app code into a `React.StrictMode` which does some double-mounting to [ensure reusable state](https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state) in non-production builds. Is this possibly what you are seeing/referring to? – Drew Reese Nov 04 '22 at 16:58
  • I believed I've fixed the loop in that codesandbox and interesting to know regarding StrictMode, which I've removed. Unfortunately the issue remains. Do you see how the state resets on navigation? That only happens when using React Transition Group – jdimmerman Nov 04 '22 at 18:03
  • @jdimmer Yes, your sandbox is working now. `MyContextProvider` is mounted when the path is `"/1/*"`. When you navigate off that path, e.g. to `"/2/*"` the `MyContextProvider` will unmount. When you navigate back to `"/1/*"` then `MyContextProvider` is mounted again with initial state. If you want the state that `MyContextProvider` maintains to persist longer or regardless of the path then it needs to be moved higher in the ReactTree so it remains mounted and can retain its state. Does this make sense? – Drew Reese Nov 04 '22 at 18:10
  • certainly! However, I would expect `MyContextProvider` to stay mounted when navigating between `/1/1` and `/1/2`, but `SwitchTransition` appears to break that expectation. – jdimmerman Nov 04 '22 at 18:28
  • @jdimmerman Ah, yes, it certainly does do that. Promoting the `MyContextProvider` higher in the ReactTree appears to resolve this though: https://codesandbox.io/s/context-provider-is-unmounting-when-using-react-transition-group-with-nested-rou-xp8qn2 The issue here is the `CSSTransition` component using `key={location.key}`. The key changes when the descendent route changes, and when React keys change then that *entire* sub-ReactTree is remounted. – Drew Reese Nov 04 '22 at 18:34
  • Ah, perhaps I can play with the key to only be change when the top route is changed. In the more complex use case, I cannot move the context provider unfortunately as state is segmented for various subroutes. Thanks! – jdimmerman Nov 04 '22 at 18:35
  • thanks! That did it. If you want to update your answer to include the fact that the children are unmounted and remounted when the key prop to `CSSTransition` changes, and therefore I needed to match only on the parent route, then I'll accept it. – jdimmerman Nov 04 '22 at 20:03
  • @jdimmerman Sure thing! I meant, and wanted, to update the answer with the new details anyway. – Drew Reese Nov 04 '22 at 20:16