In this article by Christopher Pitt, author of the book React Components, has explained how to change sections without reloading the page. We'll use this knowledge to create the public pages of the website our CMS is meant to control.
(For more resources related to this topic, see here.)
Before we can learn about alternatives to reloading pages, let's take a look at how the browser manages reloads.
You've probably encountered the window object. It's a global catch-all object for browser-based functionality and state. It's also the default this scope in any HTML page:
We've even accessed window before. When we rendered to document.body or used document.querySelector, the window object was assumed. It's the same as if we were to call window.document.querySelector.
Most of the time document is the only property we need. That doesn't mean it's the only property useful to us. Try the following, in the console:
console.log(window.location);
You should see something similar to:
Location {
hash: ""
host: "127.0.0.1:3000"
hostname: "127.0.0.1"
href: "http://127.0.0.1:3000/examples/login.html"
origin: "http://127.0.0.1:3000"
pathname: "/examples/login.html"
port: "3000"
...
}
If we were trying to work out which components to show based on the browser URL, this would be an excellent place to start. Not only can we read from this object, but we can also write to it:
<script>
window.location.href = "http://material-ui.com";
</script>
Putting this in an HTML page or entering that line of JavaScript in the console will redirect the browser to material-ui.com. It's the same if you click on the link. And if it's to a different page (than the one the browser is pointing at), then it will cause a full page reload.
So how does this help us? We're trying to avoid full page reloads, after all. Let's experiment with this object.
Let's see what happens when we add something like #page-admin to the URL:
Adding #page-admin to the URL leads to the window.location.hash property being populated with the same page. What's more, changing the hash value won't reload the page! It's the same as if we clicked on a link that had that hash in the href attribute. We can modify it without causing full page reloads, and each modification will store a new entry in the browser history.
Using this trick, we can step through a number of different "states" without reloading the page, and be able to backtrack each with the browser's back button.
Let's put this trick to use in our CMS. First, let's add a couple functions to our Nav component:
export default (props) => {
// ...define class names
var redirect = (event, section) => {
window.location.hash = `#${section}`;
event.preventDefault();
}
return <div className={drawerClassNames}>
<header className="demo-drawer-header">
<img src="images/user.jpg"
className="demo-avatar" />
</header>
<nav className={navClassNames}>
<a className="mdl-navigation__link"
href="/examples/login.html"
onClick={(e) => redirect(e, "login")}>
<i className={buttonIconClassNames}
role="presentation">
lock
</i>
Login
</a>
<a className="mdl-navigation__link"
href="/examples/page-admin.html"
onClick={(e) => redirect(e, "page-admin")}>
<i className={buttonIconClassNames}
role="presentation">
pages
</i>
Pages
</a>
</nav>
</div>;
};
We add an onClick attribute to our navigation links. We've created a special function that will change window.location.hash and prevent the default full page reload behavior the links would otherwise have caused.
This is a neat use of arrow functions, but we're ultimately creating three new functions in each render call. Remember that this can be expensive, so it's best to move the function creation out of render. We'll replace this shortly.
It's also interesting to see template strings in action. Instead of "#" + section, we can use `#${section}` to interpolate the section name. It's not as useful in small strings, but becomes increasingly useful in large ones.
Clicking on the navigation links will now change the URL hash. We can add to this behavior by rendering different components when the navigation links are clicked:
import React from "react";
import ReactDOM from "react-dom";
import Component from "src/component";
import Login from "src/login";
import Backend from "src/backend";
import PageAdmin from "src/page-admin";
class Nav extends Component {
render() {
// ...define class names
return <div className={drawerClassNames}>
<header className="demo-drawer-header">
<img src="images/user.jpg"
className="demo-avatar" />
</header>
<nav className={navClassNames}>
<a className="mdl-navigation__link"
href="/examples/login.html"
onClick={(e) => this.redirect(e, "login")}>
<i className={buttonIconClassNames}
role="presentation">
lock
</i>
Login
</a>
<a className="mdl-navigation__link"
href="/examples/page-admin.html"
onClick={(e) => this.redirect(e, "page-admin")}>
<i className={buttonIconClassNames}
role="presentation">
pages
</i>
Pages
</a>
</nav>
</div>;
}
redirect(event, section) {
window.location.hash = `#${section}`;
var component = null;
switch (section) {
case "login":
component = <Login />;
break;
case "page-admin":
var backend = new Backend();
component = <PageAdmin backend={backend} />;
break;
}
var layoutClassNames = [
"demo-layout",
"mdl-layout",
"mdl-js-layout",
"mdl-layout--fixed-drawer"
].join(" ");
ReactDOM.render(
<div className={layoutClassNames}>
<Nav />
{component}
</div>,
document.querySelector(".react")
);
event.preventDefault();
}
};
export default Nav;
We've had to convert the Nav function to a Nav class. We want to create the redirect method outside of render (as that is more efficient) and also isolate the choice of which component to render.
Using a class also gives us a way to name and reference Nav, so we can create a new instance to overwrite it from within the redirect method. It's not ideal packaging this kind of code within a component, so we'll clean that up in a bit.
We can now switch between different sections without full page reloads.
There is one problem still to solve. When we use the browser back button, the components don't change to reflect the component that should be shown for each hash. We can solve this in a couple of ways. The first approach we can try is checking the hash frequently:
componentDidMount() {
var hash = window.location.hash;
setInterval(() => {
if (hash !== window.location.hash) {
hash = window.location.hash;
this.redirect(null, hash.slice(1), true);
}
}, 100);
}
redirect(event, section, respondingToHashChange = false) {
if (!respondingToHashChange) {
window.location.hash = `#${section}`;
}
var component = null;
switch (section) {
case "login":
component = <Login />;
break;
case "page-admin":
var backend = new Backend();
component = <PageAdmin backend={backend} />;
break;
}
var layoutClassNames = [
"demo-layout",
"mdl-layout",
"mdl-js-layout",
"mdl-layout--fixed-drawer"
].join(" ");
ReactDOM.render(
<div className={layoutClassNames}>
<Nav />
{component}
</div>,
document.querySelector(".react")
);
if (event) {
event.preventDefault();
}
}
Our redirect method has an extra parameter, to apply the new hash whenever we're not responding to a hash change. We've also wrapped the call to event.preventDefault in case we don't have a click event to work with. Other than those changes, the redirect method is the same.
We've also added a componentDidMount method, in which we have a call to setInterval. We store the initial window.location.hash and check 10 times a second to see if it has change. The hash value is #login or #page-admin, so we slice the first character off and pass the rest to the redirect method.
Try clicking on the different navigation links, and then use the browser back button.
The second option is to use the newish pushState and popState methods on the window.history object. They're not very well supported yet, so you need to be careful to handle older browsers or sure you don't need to handle them.
You can learn more about pushState and popState at https://developer.mozilla.org/en-US/docs/Web/API/History_API.
Our hash code is functional but invasive. We shouldn't be calling the render method from inside a component (at least not one we own). So instead, we're going to use a popular router to manage this stuff for us.
Download it with the following:
$ npm install react-router --save
Then we need to join login.html and page-admin.html back into the same file:
<!DOCTYPE html>
<html>
<head>
<script src="/node_modules/babel-core/browser.js"></script>
<script src="/node_modules/systemjs/dist/system.js"></script>
<script src="https://storage.googleapis.com/code.getmdl.io/1.0.6/material.min.js"></script>
<link rel="stylesheet" href="https://storage.googleapis.com/code.getmdl.io/1.0.6/material.indigo-pink.min.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="admin.css" />
</head>
<body class="
mdl-demo
mdl-color--grey-100
mdl-color-text--grey-700
mdl-base">
<div class="react"></div>
<script>
System.config({
"transpiler": "babel",
"map": {
"react": "/examples/react/react",
"react-dom": "/examples/react/react-dom",
"router": "/node_modules/react-router/umd/ReactRouter"
},
"baseURL": "../",
"defaultJSExtensions": true
});
System.import("examples/admin");
</script>
</body>
</html>
Notice how we've added the ReactRouter file to the import map? We'll use that in admin.js. First, let's define our layout component:
var App = function(props) {
var layoutClassNames = [
"demo-layout",
"mdl-layout",
"mdl-js-layout",
"mdl-layout--fixed-drawer"
].join(" ");
return (
<div className={layoutClassNames}>
<Nav />
{props.children}
</div>
);
};
This creates the page layout we've been using and allows a dynamic content component. Every React component has a this.props.children property (or props.children in the case of a stateless component), which is an array of nested components. For example, consider this component:
<App>
<Login />
</App>
Inside the App component, this.props.children will be an array with a single item—an instance of the Login. Next, we'll define handler components for the two sections we want to route:
var LoginHandler = function() {
return <Login />;
};
var PageAdminHandler = function() {
var backend = new Backend();
return <PageAdmin backend={backend} />;
};
We don't really need to wrap Login in LoginHandler but I've chosen to do it to be consistent with PageAdminHandler. PageAdmin expects an instance of Backend, so we have to wrap it as we see in this example.
Now we can define routes for our CMS:
ReactDOM.render(
<Router history={browserHistory}>
<Route path="/" component={App}>
<IndexRoute component={LoginHandler} />
<Route path="login" component={LoginHandler} />
<Route path="page-admin" component={PageAdminHandler} />
</Route>
</Router>,
document.querySelector(".react")
);
There's a single root route, for the path /. It creates an instance of App, so we always get the same layout. Then we nest a login route and a page-admin route. These create instances of their respective components. We also define an IndexRoute so that the login page will be displayed as a landing page.
We need to remove our custom history code from Nav:
import React from "react";
import ReactDOM from "react-dom";
import { Link } from "router";
export default (props) => {
// ...define class names
return <div className={drawerClassNames}>
<header className="demo-drawer-header">
<img src="images/user.jpg"
className="demo-avatar" />
</header>
<nav className={navClassNames}>
<Link className="mdl-navigation__link" to="login">
<i className={buttonIconClassNames}
role="presentation">
lock
</i>
Login
</Link>
<Link className="mdl-navigation__link" to="page-admin">
<i className={buttonIconClassNames}
role="presentation">
pages
</i>
Pages
</Link>
</nav>
</div>;
};
And since we no longer need a separate redirect method, we can convert the class back into a statement component (function).
Notice we've swapped anchor components for a new Link component. This interacts with the router to show the correct section when we click on the navigation links. We can also change the route paths without needing to update this component (unless we also change the route names).
Now that we can easily switch between CMS sections, we can use the same trick to show the public pages of our website. Let's create a new HTML page just for these:
<!DOCTYPE html>
<html>
<head>
<script src="/node_modules/babel-core/browser.js"></script>
<script src="/node_modules/systemjs/dist/system.js"></script>
</head>
<body>
<div class="react"></div>
<script>
System.config({
"transpiler": "babel",
"map": {
"react": "/examples/react/react",
"react-dom": "/examples/react/react-dom",
"router": "/node_modules/react-router/umd/ReactRouter"
},
"baseURL": "../",
"defaultJSExtensions": true
});
System.import("examples/index");
</script>
</body>
</html>
This is a reduced form of admin.html without the material design resources. I think we can ignore the appearance of these pages for the moment, while we focus on the navigation.
The public pages are almost 100%, so we can use stateless components for them. Let's begin with the layout component:
var App = function(props) {
return (
<div className="layout">
<Nav pages={props.route.backend.all()} />
{props.children}
</div>
);
};
This is similar to the App admin component, but it also has a reference to a Backend. We define that when we render the components:
var backend = new Backend();
ReactDOM.render(
<Router history={browserHistory}>
<Route path="/" component={App} backend={backend}>
<IndexRoute component={StaticPage} backend={backend} />
<Route path="pages/:page" component={StaticPage} backend={backend} />
</Route>
</Router>,
document.querySelector(".react")
);
For this to work, we also need to define a StaticPage:
var StaticPage = function(props) {
var id = props.params.page || 1;
var backend = props.route.backend;
var pages = backend.all().filter(
(page) => {
return page.id == id;
}
);
if (pages.length < 1) {
return <div>not found</div>;
}
return (
<div className="page">
<h1>{pages[0].title}</h1>
{pages[0].content}
</div>
);
};
This component is more interesting. We access the params property, which is a map of all the URL path parameters defined for this route. We have :page in the path (pages/:page), so when we go to pages/1, the params object is {"page":1}.
We also pass a Backend to Page, so we can fetch all pages and filter them by page.id. If no page.id is provided, we default to 1.
After filtering, we check to see if there are any pages. If not, we return a simple not found message. Otherwise, we render the content of the first page in the array (since we expect the array to have a length of at least 1).
We now have a page for the public pages of the website:
In this article, we learned about how the browser stores URL history and how we can manipulate it to load different sections without full page reloads.
Further resources on this subject: