React, Single Page Apps, and the Browser's back button
Asked Answered
A

5

9

I know my question could simply have a "This cannot be done, this defines the purpose of SPA". But...

I navigate to mydomain.com in my REACT web app. This page loads data from the backend and populates elaborate grids. It takes it about 2 seconds to load and render.

Now I click a link on that elaborate page and navigate to mydomain.com/otherPage. When I click the browser's BACK button to return to mydomain.com, it's blank, and has to be rebuilt from scratch as SPA dictates the DOM must be erased and re-built with every page change (at least the page-specific dynamic parts of it, as routes can be inside a fixed layout of header/footer etc). I get that...

Other than migrating to nextJS and using SSR....

Is there any magic solution in REACT to somehow 'retain' the DOM for a page when navigating out of it, so that when you browser-back into it, that page is instantly shown and not rendered from scratch?

Alyshaalysia answered 17/2, 2021 at 18:51 Comment(12)
I think I misunderstood your question in my answer. What takes time, the render or the grid loading?Gabfest
If I worked on a page, loading data from the back end..... go to another page, and click back, I don't want to render the whole page again. It may be simply be impossible with Vanilla react and I must migrate to NextJS. My question doesn't concern loading times. My question concern the NEED to re-build the page from scratch instead of just showing it from memory like any classic SSR page.Alyshaalysia
Well, you can cover the grid with the new page with z-indexGabfest
Really, do you have proof that recreating the page is the bottleneck? They should be very fast unless you have heavy effectsGabfest
I don't need proof. When you go back on an SSR page it's visible immediately. When you go bak to my main page, it's blank, and starts loading from scratch. Since a server call is required, the visual elements are BLANK until a response arrives. It's not a bottleneck, it's just a flickering that's unnatural due to having to build the page from scratch. Static elements are fast so they seem fast. But react is destroying and rebuilding the dom with each page switch. It's how it works. All I ask is if there's some kind of way to cache the DOM locally to that it doesn't have to .Alyshaalysia
@Gabfest think about it this way... let's EXAGGERATE to make my point: let's say that in order to build a page in react I had to get information from 1,000 different APi server and had to wait 30 seconds for it to complete. Now I go to another page.. and now I hit BACK on the server. It would not bring me the last DOM snapshot of that page, it will re-build it, make 1000 requests again and build the DOM for that page from scratch. AlL I'm saying, is short of nextJS, can I avoid it?Alyshaalysia
For the api calls I suggested my (now deleted) answer. As I said, you can't retain the dom unless you cover it (like some dialog implementations do). Maybe a custom renderer might sove this.Gabfest
I'm working on an app that has some intensive api calls between pages, but because I am using redux to store my data, once the call is made and I navigate back to that page it loads quite quickly because I don't have to make those calls. When using the back button, at least in Chrome, chrome will cache the api calls and they'll be quicker than a full refresh but will definitely take more time. If the back button is used often, maybe you can provide a 'back' link on the page to encourage users to use that over the browser's back button? Just a suggestionAlcala
BTW, switching to next.js is not that hard. It just takes some time but it really worths. (I know you need solution with pure react but adding a bit of javascript spice wont burn the world off)Transvestite
This can be easily solved using an approach named "optimistic update" and save the data that came from the api into the cache. That way, if you navigate to a route and there was previous data, it will instantly render that and then it will go to the server and check if anything has change, if so, then update the view. There are a couple of tools for this, either you are using Redux -> Redux Saga or Apollo. Once you know the term you can look that up. Good luck!Mei
If you're using React Router, is using HashRouter or MemoryRouter an option?Santanasantayana
It's not clear if you are getting your data with AJAX or using SSR, if you are using AJAX... are you asking about how to keep the response in memory so next time you need to render the page is faster? Or are you asking about how to avoid unmounting a page when switching to a different route? Next.js can generate static content, but the page still un-mounts when witching to a different URL...Hyperacidity
B
8

Yes, it is very much possible to switch routes while keeping the DOM rendered, but hidden! If you are building a SPA, it would be a good idea to use client side routing. This makes your task easy:

For hiding, while keeping components in the DOM, use either of the following css:

  1. .hidden { visibility: hidden } only hides the unused component/route, but still keeps its layout.

  2. .no-display { display: none } hides the unused component/route, including its layout.

For routing, using react-router-dom, you can use the function children prop on a Route component:

children: func

Sometimes you need to render whether the path matches the location or not. In these cases, you can use the function children prop. It works exactly like render except that it gets called whether there is a match or not.The children render prop receives all the same route props as the component and render methods, except when a route fails to match the URL, then match is null. This allows you to dynamically adjust your UI based on whether or not the route matches.

Here in our case, I'm adding the hiding css classes if the route doesn't match:

App.tsx:

export default function App() {
  return (
    <div className="App">
      <Router>
        <HiddenRoutes hiddenClass="hidden" />
        <HiddenRoutes hiddenClass="no-display" />
      </Router>
    </div>
  );
}

const HiddenRoutes: FC<{ hiddenClass: string }> = ({ hiddenClass }) => {
  return (
    <div>
      <nav>
        <NavLink to="/1">to 1</NavLink>
        <NavLink to="/2">to 2</NavLink>
        <NavLink to="/3">to 3</NavLink>
      </nav>
      <ol>
        <Route
          path="/1"
          children={({ match }) => (
            <li className={!!match ? "" : hiddenClass}>item 1</li>
          )}
        />
        <Route
          path="/2"
          children={({ match }) => (
            <li className={!!match ? "" : hiddenClass}>item 2</li>
          )}
        />
        <Route
          path="/3"
          children={({ match }) => (
            <li className={!!match ? "" : hiddenClass}>item 3</li>
          )}
        />
      </ol>
    </div>
  );
};

styles.css:

.hidden {
  visibility: hidden;
}
.no-display {
  display: none;
}

Working CodeSandbox: https://codesandbox.io/s/hidden-routes-4mp6c?file=/src/App.tsx

Compare the different behaviours of visibility: hidden vs. display: none.

Note that in both cases, all of the components are still mounted to the DOM! You can verify with the inspect tool in the browser's dev-tools.

Reusable solution

For a reusable solution, you can create a reusable HiddenRoute component.

In the following example, I use the hook useRouteMatch, similar to how the children Route prop works. Based on the match, I provide the hidden class to the new components children:

import "./styles.css";
import {
  BrowserRouter as Router,
  NavLink,
  useRouteMatch,
  RouteProps
} from "react-router-dom";

// Reusable components that keeps it's children in the DOM
const HiddenRoute = (props: RouteProps) => {
  const match = useRouteMatch(props);
  return <span className={match ? "" : "no-display"}>{props.children}</span>;
};

export default function App() {
  return (
    <div className="App">
      <Router>
        <nav>
          <NavLink to="/1">to 1</NavLink>
          <NavLink to="/2">to 2</NavLink>
          <NavLink to="/3">to 3</NavLink>
        </nav>
        <ol>
          <HiddenRoute path="/1">
            <li>item 1</li>
          </HiddenRoute>
          <HiddenRoute path="/2">
            <li>item 2</li>
          </HiddenRoute>
          <HiddenRoute path="/3">
            <li>item 3</li>
          </HiddenRoute>
        </ol>
      </Router>
    </div>
  );
}  

Working CodeSandbox for the reusable solution: https://codesandbox.io/s/hidden-routes-2-3v22n?file=/src/App.tsx

Babbitt answered 25/2, 2021 at 19:34 Comment(0)
S
3

For API calls

You can simply put your generated elements that need intensive calculation in a state, in a component that never gets unmounted while changing page.

Here is an example with a Parent component holding 2 children and some JSX displayed after 5 seconds. When you click on the links you navigate to children, and when you click on browser's back button, you get back on the URL path. And when on / path again, the "intensive" calculation needing element is displayed immediately.

import React, { useEffect, useState } from "react";
import { Route, Link, BrowserRouter as Router } from "react-router-dom";

function Parent() {
  const [intensiveElement, setIntensiveElement] = useState("");
  useEffect(() => {
    const intensiveCalculation = async () => {
      await new Promise((resolve) => setTimeout(resolve, 5000));
      return <p>Intensive paragraph</p>;
    };
    intensiveCalculation().then((element) => setIntensiveElement(element));
  }, []);
  return (
    <Router>
      <Link to="/child1">Go to child 1</Link>
      <Link to="/child2">Go to child 2</Link>
      <Route path="/" exact>
        {intensiveElement}
      </Route>
      <Route path="/child1" exact>
        <Child1 />
      </Route>
      <Route path="/child2" exact>
        <Child2 />
      </Route>
    </Router>
  );
}

function Child1() {
  return <p>Child 1</p>;
}

function Child2() {
  return <p>Child 2</p>;
}

About redisplaying quickly the DOM

My solution above works for not doing slow things twice like API calls. But following the remarks of Mordechai, I have made an example repository to compare DOM loading time of really big HTML for 4 solutions when using browser back button:

  1. Plain html without javascript (for reference)
  2. React with the code example I gave above
  3. Next.js with next's page routing
  4. A CSS solution with React and overflow: hidden; height: 0px; (more efficient than display: none; and the elements do not take any space contrary to visibility: hidden;, opacity: 0; etc. but maybe there is a better CSS way)

Each exemple loads an initial page of 100 000 <span> elements, and has links to navigate to small pages, so that we can try the back button on the browser.

You can test yourself the static version of the examples on github pages here (the pages take several seconds to load on a normal computer, so maybe avoid clicking on them if on mobile or so).

I've added some CSS to make the elements small enough to see all of them on the screen, and compare how does the browser update the whole display.

And here are my results:

On Firefox:

  1. Plain HTML loads in ~2 sec, and back button displays page in ~1 sec
  2. Next app loads in ~2 sec, and back button displays page in ~1 sec
  3. CSS solution in React app loads in ~2 sec, and back button displays page in ~1 sec
  4. React app loads in ~2.5 sec, and back button displays page in ~2 sec

On Chrome:

  1. CSS solution in React app loads in ~2 sec, and back button displays page in ~1 sec
  2. React app loads in ~2.5 sec, and back button displays page in ~2 sec
  3. Plain HTML loads in ~8 sec, and back button displays page in ~8 sec
  4. Next app loads in ~8 sec, and back button displays page in ~8 sec

Something important to note also: for Chrome when Next.js or plain HTML take 8 seconds, they actually load elements little by little on the page, and I have no cache with the back button.

On Firefox I don't have that little by little displaying, either there is nothing or everything is displayed (like what I have on Chrome with react state usage).

I don't really know what I can conclude with that, except maybe that testing things is useful, there are sometimes surprises...

Shaw answered 24/2, 2021 at 0:24 Comment(10)
This will cache the vDOM elements (which are generally generated on each render anyway) and has nothing to do with unmounting. The issue is about the actual DOM elementsGabfest
We probably don't have the same understanding of the problem. My example works to prevent running API calls again on browser's back button, which from what I read is the actual problem here.Shaw
Here is what I read : "Since a server call is required, the visual elements are BLANK until a response arrives".Shaw
#66248719Gabfest
I also initially thought the question was about API calls (which is trivial to solve by keeping the result in an unmounted parent) but it seems not to be the case. Re effects, it won't help at all. If the children are unmounted and remounted the effects will rerun despite being persisted in stateGabfest
I can't seem to find the quote you're referring toGabfest
#66248719Shaw
I read the conversation several times and I still think for now that the problem is about not doing API calls again. Otherwise mentionning it would just be worthless. Maybe I'm wrong, it's my opinion for now.Shaw
Further reading that comment the OP seems to suggest that caching the DOM is what they're looking for. Anyway, as unclear as they are, your solution doesn't seem to helpGabfest
I'm still not sure, but it's possible that you were right about the problem. So I have updated my answer with some tests related to the DOM displaying.Shaw
T
2

I've misread the question initially. I'll leave the initial answer for the case when a user goes to a page on another domain.

Updated answer

You've wrote in the comments

I was clear enough

Well... judging by discussions here, that's not the case.

Here some points to consider, and when you'll answer them that should be the solution to your problem... whatever it is:

  1. Do you really need to make network calls on the components' mount? For an SPA it's usually a good idea to decouple your state and visual representations of it (plural!).
  2. Obviously, you need come caching mechanism. But should it be "cache" of rendered nodes of some sort (as have been suggested in every other answer) or cache of data, received from the net, or both, is up to you. And SSR - is not a caching mechanism. It exists for other reasons.
  3. Do you use any router? If, yes, then which one and how? Because some of then can retain the previous route in memory, so with a little bit of luck you could've never stumble on you blank page problem. And that can be the answer.
  4. But maybe mydomain.com/otherPage is not under control of the React or/and maybe it's not a true SPA we a talking about here. And the effects of going to this page is the same as going to another domain? Then my initial answer holds.

In a nutshell:

  1. Is there any magic solution in REACT to somehow 'retain' the DOM for a page when navigating out of it.

    • Yes, if by navigating out of it and a page you mean navigating to another route in you SPA and just rendering some other component, without executing a GET request through a "standard" <a>-click, window.location.href change or something similar which will lead to the browser initiating a new page loading.

      For that just read your router's docs.

    • No if your are actually leaving your SPA.

      For this case I would suggest serviceWorker. As to my taste, it's a much simpler and more flexible solution compared to a change of the architecture of your project with SSR.

  2. as SPA dictates the DOM must be erased and re-built with every page change

    Not at all. DOM will be erased only if the state of a component or the props are changed. But to help you with that we need to see the code.

Initial answer

It's not totally clear what is your question about. You are focused on the idea of preventing the DOM rebuild, but at the same time you're saying that the bottleneck is the API calls. And they're two quite different things to deal with.

And possible solution to you problem heavily depends on the architecture of you code.

If you have control over the server side, you can setup caching for your calls. If not, you can setup caching on the client side in a PWA-style manner.

If you have a centralized store, you can save its state to the localStorage on an <a> click and repopulate your page from the localStorage when the user gets back onto your page. If not you can resort to Service Worker API again to intercept API calls and to return cached responses. (Or just override fetch or whatever)

You can even "emulate" the SSR by saving HTML to the localStorage and showing it right away when the user gets back. (But the page will not be fully functional for a couple of seconds and need to be replaced at the moment you API-calls are completed)

But there is no feasible way to prevent DOM rebuild, because while theoretically possible, it's probably impractical to cache the whole React internal state. And if your main problem is indeed the DOM rebuild itself then probably your code is in need of serious optimizations.

Tehuantepec answered 24/2, 2021 at 4:41 Comment(4)
I was clear enough. The answer could be: NO, React can't avoid destroying the DOM and rebuilding it again when page content changes. It doesn't matter if it takes me 2 milliseconds, or 20 seconds to load a page, I was looking for a way to avoid that destruction/rebuilding without upgrading to NextJS. Seems there is no way other t han going the SSR route.Alyshaalysia
Yes "React can't avoid destroying the DOM and rebuilding it again". But no - in your case (if the bottleneck is in the API calls) PWA can be a simpler and a better solution.Tehuantepec
And to be clear: it's not the React responsibility to "destroy" the page. It's a browser's decision to do so.Tehuantepec
yep. that's what I thought. NextJS/SSR here I come...Alyshaalysia
R
2

One solution I often use, is to persist that data in location.state. And then when navigating back, the component first checks for data in location.state before attempting to fetch the data again.

This allows the page to render instantly.

const Example = (props) => {
  const history = useHistory();
  const location = useLocation();

  const initialState = location.state;
  const [state, setState] = useState(initialState);


  useEffect(() => {
    const persistentState = state;
    history.replace({ pathname: location.pathname }, persistentState);
  },[state]);

  return ();
}
Rearm answered 2/3, 2021 at 1:49 Comment(1)
This will also load the previous state after a page reload but reloading a page should certainly RELOAD a page and not bring back the old state.Lignify
F
1

Using out of the box routing, I would say: it's impossible.

But who said we need to use routes?


Solution 1:

Why not using Portals?

This probably won't work if you want to 'retain' the DOM for any navigation on your page. But if you want to 'retain' it on only one specific page, then you could just open a fullscreen portal/modal/dialog (or whatever you wanna call it).


Solution 2:

If you want to 'retain' the DOM for all navigation, then you could also write a "router-component" yourself.

The logic of your component could look like this:

First you need a lookup-table. Give every url a related component that should be rendered, when the url is called.

  1. Check if the target url has previously been open
  2. If no: create a new div and open the matching component (from the lookup) in it. Bring that div to the front (z-index)
  3. If yes: bring the related (already existing) div to the front (z-index)

Writing such a component shouldn't be too hard. I only see two problems with it:

  • performance: if you got many overlapping components open at the same time, this could slow down your page (depending on how many pages and content you got)
  • on refresh everything gets lost
Faviolafavonian answered 24/2, 2021 at 16:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.