Code for this chapter available here.
In this chapter we are going to create different pages for our app and make it possible to navigate between them.
💡 React Router is a library to navigate between pages in your React app. It can be used on both the client and the server.
- Run
yarn add react-router react-router-dom
On the client side, we first need to wrap our app inside a BrowserRouter component.
- Update your
src/client/index.jsxlike so:
// [...]
import { BrowserRouter } from 'react-router-dom'
// [...]
const wrapApp = (AppComponent, reduxStore) =>
<Provider store={reduxStore}>
<BrowserRouter>
<AppContainer>
<AppComponent />
</AppContainer>
</BrowserRouter>
</Provider>Our app will have 4 pages:
-
A Home page.
-
A Hello page showing a button and message for the synchronous action.
-
A Hello Async page showing a button and message for the asynchronous action.
-
A 404 "Not Found" page.
-
Create a
src/client/component/page/home.jsxfile containing:
// @flow
import React from 'react'
const HomePage = () => <p>Home</p>
export default HomePage- Create a
src/client/component/page/hello.jsxfile containing:
// @flow
import React from 'react'
import HelloButton from '../../container/hello-button'
import Message from '../../container/message'
const HelloPage = () =>
<div>
<Message />
<HelloButton />
</div>
export default HelloPage- Create a
src/client/component/page/hello-async.jsxfile containing:
// @flow
import React from 'react'
import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'
const HelloAsyncPage = () =>
<div>
<MessageAsync />
<HelloAsyncButton />
</div>
export default HelloAsyncPage- Create a
src/client/component/page/not-found.jsxfile containing:
// @flow
import React from 'react'
const NotFoundPage = () => <p>Page not found</p>
export default NotFoundPageLet's add some routes in the shared config file.
- Edit your
src/shared/routes.jslike so:
// @flow
export const HOME_PAGE_ROUTE = '/'
export const HELLO_PAGE_ROUTE = '/hello'
export const HELLO_ASYNC_PAGE_ROUTE = '/hello-async'
export const NOT_FOUND_DEMO_PAGE_ROUTE = '/404'
export const helloEndpointRoute = (num: ?number) => `/ajax/hello/${num || ':num'}`The /404 route is just going to be used in a navigation link for the sake of demonstrating what happens when you click on a broken link.
- Create a
src/client/component/nav.jsxfile containing:
// @flow
import React from 'react'
import { NavLink } from 'react-router-dom'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
NOT_FOUND_DEMO_PAGE_ROUTE,
} from '../../shared/routes'
const Nav = () =>
<nav>
<ul>
{[
{ route: HOME_PAGE_ROUTE, label: 'Home' },
{ route: HELLO_PAGE_ROUTE, label: 'Say Hello' },
{ route: HELLO_ASYNC_PAGE_ROUTE, label: 'Say Hello Asynchronously' },
{ route: NOT_FOUND_DEMO_PAGE_ROUTE, label: '404 Demo' },
].map(link => (
<li key={link.route}>
<NavLink to={link.route} activeStyle={{ color: 'limegreen' }} exact>{link.label}</NavLink>
</li>
))}
</ul>
</nav>
export default NavHere we simply create a bunch of NavLinks that use the previously declared routes.
- Finally, edit
src/client/app.jsxlike so:
// @flow
import React from 'react'
import { Switch } from 'react-router'
import { Route } from 'react-router-dom'
import { APP_NAME } from '../shared/config'
import Nav from './component/nav'
import HomePage from './component/page/home'
import HelloPage from './component/page/hello'
import HelloAsyncPage from './component/page/hello-async'
import NotFoundPage from './component/page/not-found'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
} from '../shared/routes'
const App = () =>
<div>
<h1>{APP_NAME}</h1>
<Nav />
<Switch>
<Route exact path={HOME_PAGE_ROUTE} render={() => <HomePage />} />
<Route path={HELLO_PAGE_ROUTE} render={() => <HelloPage />} />
<Route path={HELLO_ASYNC_PAGE_ROUTE} render={() => <HelloAsyncPage />} />
<Route component={NotFoundPage} />
</Switch>
</div>
export default App🏁 Run yarn start and yarn dev:wds. Open http://localhost:8000, and click on the links to navigate between our different pages. You should see the URL changing dynamically. Switch between different pages and use the back button of your browser to see that the browsing history is working as expected.
Now, let's say you navigated to http://localhost:8000/hello this way. Hit the refresh button. You now get a 404, because our Express server only responds to /. As you navigated between pages, you were actually only doing it on the client-side. Let's add server-side rendering to the mix to get the expected behavior.
💡 Server-Side Rendering means rendering your app at the initial load of the page instead of relying on JavaScript to render it in the client's browser.
SSR is essential for SEO and provides a better user experience by showing the app to your users right away.
The first thing we're going to do here is to migrate most of our client code to the shared / isomorphic / universal part of our codebase, since the server is now going to render our React app too.
- Move all the files located under
clienttoshared, exceptsrc/client/index.jsx.
We have to adjust a whole bunch of imports:
-
In
src/client/index.jsx, replace the 3 occurrences of'./app'by'../shared/app', and'./reducer/hello'by'../shared/reducer/hello' -
In
src/shared/app.jsx, replace'../shared/routes'by'./routes'and'../shared/config'by'./config' -
In
src/shared/component/nav.jsx, replace'../../shared/routes'by'../routes'
- Create a
src/server/routing.jsfile containing:
// @flow
import {
homePage,
helloPage,
helloAsyncPage,
helloEndpoint,
} from './controller'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
helloEndpointRoute,
} from '../shared/routes'
import renderApp from './render-app'
export default (app: Object) => {
app.get(HOME_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, homePage()))
})
app.get(HELLO_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, helloPage()))
})
app.get(HELLO_ASYNC_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, helloAsyncPage()))
})
app.get(helloEndpointRoute(), (req, res) => {
res.json(helloEndpoint(req.params.num))
})
app.get('/500', () => {
throw Error('Fake Internal Server Error')
})
app.get('*', (req, res) => {
res.status(404).send(renderApp(req.url))
})
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
// eslint-disable-next-line no-console
console.error(err.stack)
res.status(500).send('Something went wrong!')
})
}This file is where we deal with requests and responses. The calls to business logic are externalized to a different controller module.
Note: You will find a lot of React Router examples using * as the route on the server, leaving the entire routing handling to React Router. Since all requests go through the same function, that makes it inconvenient to implement MVC-style pages. Instead of doing that, we're here explicitly declaring the routes and their dedicated responses, to be able to fetch data from the database and pass it to a given page easily.
- Create a
src/server/controller.jsfile containing:
// @flow
export const homePage = () => null
export const helloPage = () => ({
hello: { message: 'Server-side preloaded message' },
})
export const helloAsyncPage = () => ({
hello: { messageAsync: 'Server-side preloaded message for async page' },
})
export const helloEndpoint = (num: number) => ({
serverMessage: `Hello from the server! (received ${num})`,
})Here is our controller. It would typically make business logic and database calls, but in our case we just hard-code some results. Those results are passed back to the routing module to be used to initialize our server-side Redux store.
- Create a
src/server/init-store.jsfile containing:
// @flow
import Immutable from 'immutable'
import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import helloReducer from '../shared/reducer/hello'
const initStore = (plainPartialState: ?Object) => {
const preloadedState = plainPartialState ? {} : undefined
if (plainPartialState && plainPartialState.hello) {
// flow-disable-next-line
preloadedState.hello = helloReducer(undefined, {})
.merge(Immutable.fromJS(plainPartialState.hello))
}
return createStore(combineReducers({ hello: helloReducer }),
preloadedState, applyMiddleware(thunkMiddleware))
}
export default initStoreThe only thing we do here, besides calling createStore and applying middleware, is to merge the plain JS object we received from the controller into a default Redux state containing Immutable objects.
- Edit
src/server/index.jslike so:
// @flow
import compression from 'compression'
import express from 'express'
import routing from './routing'
import { WEB_PORT, STATIC_PATH } from '../shared/config'
import { isProd } from '../shared/util'
const app = express()
app.use(compression())
app.use(STATIC_PATH, express.static('dist'))
app.use(STATIC_PATH, express.static('public'))
routing(app)
app.listen(WEB_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' :
'(development).\nKeep "yarn dev:wds" running in an other terminal'}.`)
})Nothing special here, we just call routing(app) instead of implementing routing in this file.
- Rename
src/server/render-app.jstosrc/server/render-app.jsxand edit it like so:
// @flow
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Provider } from 'react-redux'
import { StaticRouter } from 'react-router'
import initStore from './init-store'
import App from './../shared/app'
import { APP_CONTAINER_CLASS, STATIC_PATH, WDS_PORT } from '../shared/config'
import { isProd } from '../shared/util'
const renderApp = (location: string, plainPartialState: ?Object, routerContext: ?Object = {}) => {
const store = initStore(plainPartialState)
const appHtml = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={location} context={routerContext}>
<App />
</StaticRouter>
</Provider>)
return (
`<!doctype html>
<html>
<head>
<title>FIX ME</title>
<link rel="stylesheet" href="${STATIC_PATH}/css/style.css">
</head>
<body>
<div class="${APP_CONTAINER_CLASS}">${appHtml}</div>
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(store.getState())}
</script>
<script src="${isProd ? STATIC_PATH : `http://localhost:${WDS_PORT}/dist`}/js/bundle.js"></script>
</body>
</html>`
)
}
export default renderAppReactDOMServer.renderToString is where the magic happens. React will evaluate our entire shared App, and return a plain string of HTML elements. Provider works the same as on the client, but on the server, we wrap our app inside StaticRouter instead of BrowserRouter. In order to pass the Redux store from the server to the client, we pass it to window.__PRELOADED_STATE__ which is just some arbitrary variable name.
Note: Immutable objects implement the toJSON() method which means you can use JSON.stringify to turn them into plain JSON strings.
- Edit
src/client/index.jsxto use that preloaded state:
import Immutable from 'immutable'
// [...]
/* eslint-disable no-underscore-dangle */
const composeEnhancers = (isProd ? null : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
const preloadedState = window.__PRELOADED_STATE__
/* eslint-enable no-underscore-dangle */
const store = createStore(combineReducers(
{ hello: helloReducer }),
{ hello: Immutable.fromJS(preloadedState.hello) },
composeEnhancers(applyMiddleware(thunkMiddleware)))Here with feed our client-side store with the preloadedState that was received from the server.
🏁 You can now run yarn start and yarn dev:wds and navigate between pages. Refreshing the page on /hello, /hello-async, and /404 (or any other URI), should now work correctly. Notice how the message and messageAsync vary depending on if you navigated to that page from the client or if it comes from server-side rendering.
💡 React Helmet: A library to inject content to the
headof a React app, on both the client and the server.
I purposely made you write FIX ME in the title to highlight the fact that even though we are doing server-side rendering, we currently do not fill the title tag properly (or any of the tags in head that vary depending on the page you're on).
-
Run
yarn add react-helmet -
Edit
src/server/render-app.jsxlike so:
import Helmet from 'react-helmet'
// [...]
const renderApp = (/* [...] */) => {
// [...]
const appHtml = ReactDOMServer.renderToString(/* [...] */)
const head = Helmet.rewind()
return (
`<!doctype html>
<html>
<head>
${head.title}
${head.meta}
<link rel="stylesheet" href="${STATIC_PATH}/css/style.css">
</head>
[...]
`
)
}React Helmet uses react-side-effect's rewind to pull out some data from the rendering of our app, which will soon contain some <Helmet /> components. Those <Helmet /> components are where we set the title and other head details for each page. Note that Helmet.rewind() must come after ReactDOMServer.renderToString().
- Edit
src/shared/app.jsxlike so:
import Helmet from 'react-helmet'
// [...]
const App = () =>
<div>
<Helmet titleTemplate={`%s | ${APP_NAME}`} defaultTitle={APP_NAME} />
<Nav />
// [...]- Edit
src/shared/component/page/home.jsxlike so:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import { APP_NAME } from '../../config'
const HomePage = () =>
<div>
<Helmet
meta={[
{ name: 'description', content: 'Hello App is an app to say hello' },
{ property: 'og:title', content: APP_NAME },
]}
/>
<h1>{APP_NAME}</h1>
</div>
export default HomePage- Edit
src/shared/component/page/hello.jsxlike so:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import HelloButton from '../../container/hello-button'
import Message from '../../container/message'
const title = 'Hello Page'
const HelloPage = () =>
<div>
<Helmet
title={title}
meta={[
{ name: 'description', content: 'A page to say hello' },
{ property: 'og:title', content: title },
]}
/>
<h1>{title}</h1>
<Message />
<HelloButton />
</div>
export default HelloPage- Edit
src/shared/component/page/hello-async.jsxlike so:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'
const title = 'Async Hello Page'
const HelloAsyncPage = () =>
<div>
<Helmet
title={title}
meta={[
{ name: 'description', content: 'A page to say hello asynchronously' },
{ property: 'og:title', content: title },
]}
/>
<h1>{title}</h1>
<MessageAsync />
<HelloAsyncButton />
</div>
export default HelloAsyncPage- Edit
src/shared/component/page/not-found.jsxlike so:
// @flow
import React from 'react'
import Helmet from 'react-helmet'
const title = 'Page Not Found'
const NotFoundPage = () =>
<div>
<Helmet
title={title}
meta={[
{ name: 'description', content: 'A page to say hello' },
{ property: 'og:title', content: title },
]}
/>
<h1>{title}</h1>
</div>
export default NotFoundPageThe <Helmet> component doesn't actually render anything, it just injects content in the head of your document and exposes the same data to the server.
🏁 Run yarn start and yarn dev:wds and navigate between pages. The title on your tab should change when you navigate, and it should also stay the same when you refresh the page. Show the source of the page to see how React Helmet sets the title and meta tags even for server-side rendering.
Next section: 07 - Socket.IO
Back to the previous section or the table of contents.