In this chapter, we’ll explore another cornerstone of React, the ability to generate HTML on the server in addition to being able to render directly to the DOM. This enables creation of isomorphic applications, that is, applications that use the same codebase on the server as well as the client to do either task: render to the DOM or create HTML.
Server rendering (also known as server-side rendering or SSR for short) is the opposite of what characterizes an SPA: rather than fetch data via APIs and construct the DOM on the browser, the entire HTML is constructed on the server and sent to the browser. The need for it arises when the application needs to be indexed by search engines. Search engine bots typically start from the root URL (/) and then traverse all the hyperlinks present in the resulting HTML. They do not execute JavaScript to fetch data via Ajax calls and look at the changed DOM. So, to have pages from an application be properly indexed by search engines, the server needs to respond with the same HTML that will result after the Ajax call in componentDidMount() methods and subsequent re-rendering of the page.
For example, if a request is made to /issues, the HTML returned should have the list of issues in the table prepopulated. The same goes for all other pages that can be bookmarked or have a hyperlink pointing to them. But this defeats the purpose of SPAs, which provide a better user experience when the user navigates within the application. So, the way we’ll make it work is as follows:
The first time any page is opened in the application (e.g., by typing in the URL in the browser, or even choosing refresh on the browser when already in any page in the application), the entire page will be constructed and returned from the server. We’ll call this server rendering.
Once any page is loaded and the user navigates to another page, we’ll make it work like an SPA. That is, only the API will be made and the DOM will be modified directly on the browser. We’ll call this browser rendering.
Note that this is true for any page, not just for the home page. For example, the user could type in the URL of the Edit page of an issue in the browser, and that page will be constructed at the server and returned too.
Since the steps to achieve all this are not simple, we’ll create a new simple page—the About page—for mastering the techniques required. We’ll start with a static page, then add complexity by using an API to fetch the data it renders. Once we’ve perfected the technique to render the About page at the server, we’ll extend the changes required to all other pages.
A note of caution: not all applications need server rendering. If an application does not need to be indexed by search engines, the complexity introduced by server rendering can be avoided. Typically, if the pages do not have public access, they don’t need search engine indexing. The performance benefits alone do not justify the complexity of server rendering.
New Directory Structure
Until now, we didn’t give too much attention to the directory structure for the UI. That’s because all the code under the src directory was meant to be compiled into a bundle and served to the browser. This is no longer going to be the case. What we’ll need instead is three sets of files:
All the shared files, essentially, all the React components.
A set of files used to run the UI server using Express. This will import the shared React components for server rendering.
A starting point for the browser bundle, one that includes all the shared React components and can be sent to the browser to execute.
So, let’s split the current source code into three directories, one for each set of files. Let’s keep the shared React components in the src directory. As for the browser-specific and server-specific files, let’s create two directories called browser and server respectively. Then, let’s move the browser-specific file App.jsx into its directory and the server-specific file uiserver.js into the server directory.
$ cd ui
$ mkdir browser
$ mkdir server
$ mv src/App.jsx browser
$ mv uiserver.js server
This change will also require that the linting, compiling, and bundling configurations change to reflect the new directory structure. Let’s start with the linting changes. Let’s have four .eslintrc files, one at the base (ui), and one in each of the sub-directories—the src, browser, and server directories—so that each of these inherits from the one at the root. At the .eslintrc in the ui directory, all we need to do is specify the rule set to inherit from, that is, airbnb. The changes for this are shown in Listing 12-1.
...
{
"extends": "airbnb-base",
"env": {
"node": true
},
"rules": {
"no-console": "off"
}
}
...
Listing 12-1
ui/.eslintrc: Changes to Keep Only the Base
Next, in the shared src folder, let’s add node: true for the environment, to indicate that these set of files are meant to be run in Node.js as well as in the browser. We’ll also remove the extends specification since that will be inherited from the parent directory’s .eslintrc. The changes for this are shown in Listing 12-2.
...
{
"extends": "airbnb",
"env": {
"browser": true,
"node": true
},
rules: {
...
}
...
Listing 12-2
ui/src/.eslintrc: Changes to Add Node
Now, let’s create a new .eslintrc in the browser directory, which is the same as the original .eslintrc in the src directory, without the Node.js environment. This new file is shown in Listing 12-3.
ui/browser/.eslintrc: New ESLint Configuration for Browser Source Files
As for the server .eslintrc, it’s a copy of the original one in the ui directory, specifying just the environment (Node.js only) and allowing console messages. This is shown in Listing 12-4.
{
"env": {
"node": true
},
"rules": {
"no-console": "off"
}
}
Listing 12-4
ui/server/.eslintrc: New ESLint Configuration for Server Source Files
As a next step, let’s add a .babelrc in the browser directory, which is a copy of the one in the shared src directory.
$ cd ui
$ cp src/.babelrc browser
We’ll also need to change the location of imported/required files in App.jsx and uiserver.js. These are shown in Listings 12-5 and 12-6.
...
import Page from '../src/Page.jsx';
...
Listing 12-5
ui/browser/App.jsx: New Location of Page.jsx
...
const config = require('./webpack.config.js');
const config = require('../webpack.config.js');
...
Listing 12-6
ui/server/uiserver.js: New Location of Webpack Config File
Finally, we’ll need to change the entry points in package.json for the new location of the UI server start-up file and webpack.config.js for the location of App.jsx. These are shown in Listings 12-7 and 12-8.
"start": "nodemon -w server -w .env server/uiserver.js",
...
}
...
Listing 12-7
ui/package.json: Changes for Entry Point of uiserver.js
...
module.exports = {
...
entry: { app: ['./src/App.jsx'] },
entry: { app: ['./browser/App.jsx'] },
...
}
Listing 12-8
ui/webpack.config.js: Changes for Entry Point of the Bundle
After these changes, the application should work just as before. You should test both with HMR enabled as well as HMR disabled and manually compile the browser bundle using npm run compile before starting the server.
Basic Server Rendering
We used the ReactDOM.render() method to render a React element into the DOM. The counterpart method that is to be used to create an HTML on the server side is ReactDOMServer.renderToString(). Although the method itself is a simple one, the changes that we need to make in order to use it are not. So, let’s use a simple About component to get familiar with the fundamentals. Then, in later sections, we’ll use the same technique for the other components in the application.
The code for the very basic About component is shown in Listing 12-9.
import React from 'react';
export default function About() {
return (
<div className="text-center">
<h3>Issue Tracker version 0.9</h3>
<h4>
API version 1.0
</h4>
</div>
);
}
Listing 12-9
ui/src/About.jsx: New About Component
Let’s include the new component in the application so that it’s shown when the extended menu item About is clicked. The first change needed for this is in the set of routes, so that /about loads the About component. This change to Contents.jsx is shown in Listing 12-10.
...
import IssueEdit from './IssueEdit.jsx';
import About from './About.jsx';
...
<Switch>
...
<Route path="/report" component={IssueReport} />
<Route path="/about" component={About} />
...
</Switch>
...
Listing 12-10
ui/src/Contents.jsx: Include About Component in the Application
We also need to change the menu item so that instead of a dummy, it links to /about. This change to Page.jsx is shown in Listing 12-11.
...
function NavBar() {
return (
...
<LinkContainer to="/about">
<MenuItem>About</MenuItem>
</LinkContainer>
...
);
}
...
Listing 12-11
ui/src/Page.jsx: Include About in the Navigation Bar
Now, if you point your browser to http://localhost:8000/ and navigate to the new page by clicking on the About extended menu item, you should see the About page, just like any of the other pages in the application. A screenshot of this page is shown in Figure 12-1.
Figure 12-1
The About page
But in this case, the component was browser rendered, just as all other components were being rendered until now. A sequence diagram of the events that lead to the About page being displayed using browser rendering is shown in Figure 12-2.
Figure 12-2
Sequence diagram for browser rendering
Here’s an explanation of what happens during browser rendering. The explanation and the diagram are for the About page, but the sequence is identical for any other page:
1.
The user types in the URL of the home page or refreshes the browser at the home page.
2.
The UI server returns index.html, which has a reference to the JavaScript app.bundle.js. That is also fetched, and it contains the react components, including the About component. Now, the page is considered loaded. (The Issue List component will also be mounted, but that’s not of importance at the moment.)
3.
The user clicks on the link to the About page.
4.
React mounts and renders the About component, whose code is part of the JavaScript bundle. At this point, all the static text in the component is seen.
5.
Once the initial mount is completed, componentDidMount() is called, which will trigger a call to the API server to fetch the data for the component. We have not implemented this yet, but you should be able to appreciate this by considering the other pages that we have implemented, for example, the Issue List page.
6.
On successful fetch of the data using the API call, the component is re-rendered with the data.
Next, let’s render the About component on the server. Since the server is yet to use JSX compilation, we’ll need to compile it manually to pure JavaScript so that the server can include it:
$ cd ui
$ npx babel src/About.jsx --out-dir server
This will result in a file called About.js in the server directory. Now, on the server, after importing About.js, we can render a string representation of the component using the following code snippet:
But this is going to produce only the markup for the About component. We still need the rest of the HTML such as the <head> section, with the component inserted in the contents <div>. So, let’s make a template out of the existing index.html that can accept the contents of the <div> and return the complete HTML.
Powerful templating languages such as pug can be used for this, but our requirement is quite simple, so we’ll just use the ES2015 template strings feature. Let’s place this function in a file called template.js under the server directory. The template string is the same as the contents of index.html, except that the <script> tags have been removed. This entire contents of this file are shown in Listing 12-12. The changes highlighted are differences from index.html.
.panel-title a {display: block; width: 100%; cursor: pointer;}
</style>
</head>
<body>
<!-- Page generated from template. -->
<div id="contents">${body}</div>
<script src="/env.js"></script>
<script src="/vendor.bundle.js"></script>
<script src="/app.bundle.js"></script>
</body>
</html>
`;
}
module.exports = template;
Listing 12-12
ui/server/template.js: Template for Server Rendered HTML
We’ll eventually add the scripts, but for the moment, it’s better to test the changes without this complication. As for import of the About component and rendering it to string, let’s do this in a new file in the server directory, in a function called render(). The function will take in a regular request and response like any Express route handler. It will then send out the template as a response, with the body replaced by the markup created from ReactDOMServer.renderToString().
Since the code in the server directory is still pure JavaScript (there’s no compilation step yet), let’s not use JSX to instantiate the About component. Instead, let’s use React.createElement:
...
const body = ReactDOMServer.renderToString(
React.createElement(About),
);
...
There’s one more minor incompatibility between the code in src and server directories. These use different ways of including other files and modules. You may recall from Chapter 8, “Modularization and Webpack,” that the React code uses the import/export paradigm rather than the require/module.exports way of including modules as in the server code. Fortunately, the two are compatible, with a minor change. The Babel compiler places any variables exported using the export default keywords also in module.exports, but using the property default. Thus, we’ll have to add default after importing the About component using require():
...
const About = require('./About.js').default;
...
The complete code for this new file is shown in Listing 12-13.
Now, in uiserver.js, we can set this function as the handler for the route with the /about path. Let’s add this route just before the catch-all route that returns index.html. This change, along with the import statement for including render.js, is shown in Listing 12-14.
...
const proxy = require('http-proxy-middleware');
const render = require('./render.js');
...
app.get('/about', render);
app.get('*', (req, res) => {
...
});
...
Listing 12-14
ui/server/uiserver.js: New Route for /About to Return Server-Rendered About
At this point, About.js needs to be compiled manually. Do that using the npx babel command and then restart the server. If you point your browser to http://localhost:8000/about, you should see the About component without any adornment of the navigation bar. This is because we replaced the placeholder ${body} with the About component and have not the routed Page component. A screenshot of this plain About page rendered at the server is shown in Figure 12-3.
Figure 12-3
The server rendered About page
Although this is a bit different from the browser-rendered version (which we’ll fix in later sections), it is good enough to demonstrate that the same component can be rendered at the server as well as the browser. The sequence diagram in Figure 12-4 explains in detail what happens during server rendering, which includes a step where data is fetched from the API server that we’ve yet to implement.
Figure 12-4
Sequence diagram for server rendering
Here is an explanation of the sequence of steps that lead to server rendering:
1.
The user types in the URL for the About page (or chooses refresh in the browser while on the About page).
2.
The browser sends a request to /about to the UI server.
3.
The UI server fetches the data required for the page from the API server using a GraphQL API call. We’ll implement this in later sections in this chapter.
4.
On the UI server, ReactDOM.renderToString() is called with the About component and its data.
5.
The server returns an HTML, with the markup for the About page included in it.
6.
The browser converts the HTML to the DOM and the About page is visible in the browser.
Exercise: Basic Server Rendering
1.
Let’s say the string representation of the component rendered in the server was quite large. Creating a string from the template in memory would take up a lot of memory and be slow. What option do you have in this case? Hint: Look up the documentation of ReactDOMServer at https://reactjs.org/docs/react-dom-server.html to see what other methods are available.
Answers are available at the end of the chapter.
Webpack for the Server
At this point, we just have a simple About component. We’ll need it to get data by calling the about API and rendering it on the server. We’ll do all that in the following sections, but before that, let’s deal with the inconvenience of having to compile About.js manually from About.jsx. Soon, we’ll have to compile all the files under the src directory for inclusion in the server side, and a manual compilation can become unviable.
Further, you also saw that the import/export paradigm and require/module.exports paradigm, although compatible, are not convenient when mixed. One needs to remember adding the .default after every require() of a file that uses the import/export paradigm.
It turns out that Webpack can be used for the server as well, and it can compile JSX on the fly. This will also let us consistently use the import/export paradigm in the UI server codebase. Webpack works quite the same as with the front-end code, but for one difference. Many server-side Node packages such as Express are not compatible with Webpack. They import other packages dynamically, making it hard for Webpack to follow the dependency chain. So, we’ll have to exclude the third-party libraries from the bundle and rely on node_modules to be present in the UI server’s file system.
The first thing we’ll do is add a new section in the webpack.config.js file for the server configuration. Webpack allows an array of configurations to be exported. When it encounters an array, Webpack executes all configurations in this array. The outline of the new webpack.config.js file is like this:
...
const browserConfig = {
mode: 'development',
entry: { app: ['./browser/App.jsx'] },
...
}
const serverConfig = {
mode: 'development',
entry: { server: ['./server/uiserver.js'] },
...
}
module.exports = [browserConfig, serverConfig];
...
The browserConfig variable contains the original configuration contents. One issue of using shared files between the server and the browser is that we can’t use the same Babel configuration. When compiling for Node.js, the target is Node’s latest version alone, whereas when compiling for the browser, it needs a list of browser versions. So, let’s get rid of .babelrc in the src and browser directories and instead configure Babel options via Webpack. That way, we can tell Babel what options to use based on the target: browser or server.
$ cd ui
$ rm src/.babelrc browser/.babelrc
Now, in the browserConfig section of webpack.config.js, we can specify these options like this:
...
use: 'babel-loader',
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
targets: {
ie: '11',
...
},
}],
'@babel/preset-react',
],
},
},
...
For the server configuration, we’ll need an output specification. Let’s compile the bundle into a new directory called dist (short for distribution) and call the bundle server.js.
...
const serverConfig = {
...
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
},
...
As for the Babel configuration for the server, let’s compile all js and jsx files to target Node.js version 10 and include the React preset.
...
const serverConfig = {
...
module: {
rules: [
{
test: /\.jsx?$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
targets: { node: '10' },
}],
'@babel/preset-react',
],
},
},
},
],
},
...
This configuration is not going to work because it does not exclude the modules in node_modules. We could use the same strategy as we did for the browser configuration, but the recommended way to do this on the server is to use the webpack-node-externals module, which works better. Let’s install that package.
$ cd ui
$ npm install --save-dev webpack-node-externals@1
Now, in the Webpack configuration, we can import this package and use it in the server part of the configuration like this:
The final contents of webpack.config.js are shown in Listing 12-15. This includes a source-map specification that I have not explicitly mentioned for the server configuration. Also, I have not shown the deleted code for the sake of brevity: the entire contents of the file are listed.
ui/webpack.config.js: Full Listing of File for Including Server Configuration
Now, we are ready to convert all the require() statements to import statements. But before we do that, since we are deviating from the norm of not specifying extensions in imports, we’ll have to disable the ESLint setting for this. This change to the server-side .eslintrc is shown in Listing 12-16.
Let’s first change template.js to use the import/export paradigm. The changes for this are shown in Listing 12-17.
...
export default function template(body) {
...
}
...
module.exports = template;
...
Listing 12-17
ui/server/template.js: Use Import/Export
As for render.js, let’s change all require() statements to import statements. Also, now that we can handle JSX at the server side as part of the bundling process, let’s replace React.createElement() with JSX and change the file’s extension to reflect this fact.
$ cd ui
$ mv server/render.js server/render.jsx
The new contents of the render.jsx file are shown in Listing 12-18.
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import About from '../src/About.jsx';
import template from './template.js';
function render(req, res) {
const body = ReactDOMServer.renderToString(<About />);
res.send(template(body));
}
export default render;
Listing 12-18
ui/server/render.jsx: New File for Rendering, Using JSX
In the main server file uiserver.js, apart from the require() statement to import changes, we will also need to change the HMR initialization routine that loads up the initial configuration. Now that the configuration exports an array, we’ll use the first configuration in that array rather than use the configuration as is.
Since we are now executing a bundle, when any error is encountered on the server, the line numbers that are shown in stack traces are that of the bundle’s. This is not at all convenient when we encounter errors. The source-map-support module solves this problem. On the front-end, the source-map support module also made it convenient to add breakpoints. On the back-end, all it does is make error messages more readable.
Let’s install the source-map-support package:
$ cd ui
$ npm install source-map-support@0
We can now install this support in the main server file uiserver.js like this:
...
import SourceMapSupport from 'source-map-support';
...
SourceMapSupport.install();
...
The final changes to uiserver.js are shown in Listing 12-19.
...
require('dotenv').config();
const path = require('path');
const express = require('express');
const proxy = require('http-proxy-middleware');
const render = require('./render.js');
import dotenv from 'dotenv';
import path from 'path';
import express from 'express';
import proxy from 'http-proxy-middleware';
import SourceMapSupport from 'source-map-support';
ui/server/uiserver.js: Changes for Using Import, Source Maps, and Webpack Config Array Element
Let’s change the scripts section in package.json to use the bundle to start the server instead of the file uiserver.js. Let’s also change the ESLint command to reflect the new directory structure. These changes are shown in Listing 12-20.
...
"scripts": {
...
"start": "nodemon -w server -w .env server/uiserver.js",
The manually generated About.js file is no longer needed, so let’s clean it up.
$ cd ui
$ rm server/About.js
The server bundle can be built using the following manual compilation command:
$ cd ui
$ npx webpack
Now, you can start the application using npm start and check it out. There should be no changes in the application’s behavior. You can try the About page, both directly and by loading /issues and navigating from there. The two avatars will continue to be different since we are yet to return an HTML with the navigation bar, etc. while rendering from the server.
HMR for the Server
Although using Webpack for the server does simplify the compilation process, you’ll find that during development, you still need to restart the server for every change. You could use the nodemon wrapper by running npm start, but even there you’ll find that the front-end HMR doesn’t work. This is because, on a restart, the HMR middleware is reinstalled, but the browser tries to connect to the original HMR, which is no longer there.
The solution to all this is to automatically reload the modules even in the back-end, using HMR. Since we are using Webpack to bundle the server, this should work. But the fact is that the Express already has stored references to any existing modules and it needs to be told to replace these modules when accepting HMR changes. Although it can be done, setting this up is quite complex.
So, let’s take the easy way out: we’ll only reload changes to modules based on the shared folder. As for changes to uiserver.js itself, we expect these to be very few and far between, so let’s restart the server manually when this file is changed and use HMR for the rest of the code that it includes.
Let’s start by creating a new Webpack configuration that enables HMR for the server. This configuration should be different from the one used to create a production bundle. For the browser, we added HMR dynamically as part of the UI server (by loading the configuration and modifying it in the server code). But since we don’t have a server to serve the bundle in the case of the server code, we’ll have to create a separate configuration file. Rather than copy the entire configuration file and make changes to it, let’s instead base it on the original configuration and merge the changes required for HMR. A package called webpack-merge comes in handy for this.
$ cd ui
$ npm install --save-dev webpack-merge@4
Let’s use this to merge HMR changes on top of the server configuration in a new file called webpack.serverHMR.js. In this file, let’s first load up the base configuration from the main configuration file. Note that the server configuration is the second element in the array.
Then, let’s merge the serverConfig with new changes: we’ll add a new entry point that polls for changes, and we’ll add the HMR plugin to this configuration. The complete new file is shown in Listing 12-21.
ui/webpack.serverHMR.js: Merged Configuration for Server HMR
Now, the server bundle can be rebuilt on changes if you run Webpack with this file as the configuration with the watch option. Also, it will let the running server listen for changes and load up the changed modules. The command to run Webpack with this configuration is as follows:
$ cd ui
$ npx webpack -w --config webpack.serverHMR.js
But, HMR is not going to work because the server is not (yet) accepting changes. As discussed, let’s only accept changes to render.jsx. Thus, in uiserver.js, we can add the following at the end of the file:
...
if (module.hot) {
module.hot.accept('./render.jsx');
}
...
But, this has the effect only of loading the changed module and replacing the variable render in this file to reference the new changed module. The Express route for /about still has a handle to the old render function. Ideally, we should tell the Express route that there is a new render function, perhaps like this:
...
if (module.hot) {
module.hot.accept('./render.jsx', () => {
app.get('/about', render);
});
}
Unfortunately, this ends up installing another route instead of replacing the existing one. There is also no way to unmount a route in Express. To get around this, instead of passing a reference to the function to the Express route handler, let’s create a function wrapper and call render() explicitly within this. This way, the render function that is called is always the latest one. This change, along with the module accept change, is shown in Listing 12-22.
...
app.get('/env.js', (req, res) => {
...
});
app.get('/about', render);
app.get('/about', (req, res, next) => {
render(req, res, next);
});
...
app.listen(port, () => {
...
});
if (module.hot) {
module.hot.accept('./render.jsx');
}
...
Listing 12-22
ui/server/uiserver.js: Changes for HMR
Finally, let’s change package.json’s script section to add convenience scripts for starting the UI server. We can now change the start script to remove nodemon (since HMR will load the modules automatically). Then, let’s replace the watch script with a watch-server-hmr script that runs the webpack.serverHMR.js configuration in the watch mode. Since both this and the start script are needed for starting the UI server in development mode, let’s add a script called dev-all that does both, one after the other.
In npm scripts, multiple commands can be combined using the & operator. The commands are started up simultaneously. Just to safeguard the server.js bundle being built before the npm start command is run, it’s good to have a sleep command before the npm start command. The amount of time to wait can vary depending on how fast your computer is and how long it takes to compile the server files. To start off, you could use a sleep timer of five seconds and customize this based on your needs.
The changes to the package.json scripts are shown in Listing 12-23, but the script dev-all works only on MacOS and Linux.
On a Windows PC, you may need to create your own batch file with equivalent commands or execute npm watch-server-hmr and npm start on different command windows.
Now, you can stop all other UI server commands and restart it using the single npm run dev-all command. The application should work just as before, but most changes should automatically reflect without having to restart this command.
Server Router
The way the About page was rendered from the server was different from the way it was rendered by navigating to it from /issues. In the first case, it was displayed without the navigation bar, and in the second, with it.
The reason this happened is as follows. On the browser, App.jsx mounted the Page component on to the contents div. But, on the server, the About component was rendered directly within the contents div, by stuffing it in the template.
On the server, wrapping a Router around the page, or using Switch or NavLinks, will throw up errors. This is because the Router is really meant for the DOM, where on clicking of a route’s link, the browser’s history is manipulated and different components are loaded based on the routing rules.
On the server, React Router recommends that we use a StaticRouter in place of a BrowserRouter. Also, whereas the BrowserRouter looks at the browser’s URL, the StaticRouter has to be supplied the URL. Based on this, the router will choose an appropriate component to render. StaticRouter takes a property called location, which is a static URL that the rest of the rendering will need. It also needs a property called context, whose purpose is not obvious right now, so let’s just supply an empty object for it.
Let’s then modify render.js to render the Page component instead of the About component, but wrapped around by a StaticRouter. The changes for this are shown in Listing 12-24.
...
import { StaticRouter } from 'react-router-dom';
import About from '../src/About.jsx';
import Page from '../src/Page.jsx';
...
function render(req, res) {
const body = ReactDOMServer.renderToString(<About />);
const element = (
<StaticRouter location={req.url} context={{}}>
<Page />
</StaticRouter>
);
const body = ReactDOMServer.renderToString(element);
res.send(template(body));
}
...
Listing 12-24
ui/server/render.jsx: Changes to Render Page Instead of About Directly
Now, if you test the application, you will find that both server rendering and browser rendering are identical for the About page: the navigation bar will appear in both cases. To test server rendering, you will need to press refresh on the browser while on the About page. As for browser rendering, you will need to refresh the browser in another page, say the Issue List page, and then navigate to the About page using the extended menu. Refer to the screenshot in Figure 12-1 to recap how it looks.
Note that at this point, except for the About page, the other pages are only being rendered on the browser, even when refreshing. We will address that soon, once we perfect the About page server rendering.
Exercise: Server Router
1.
Press refresh on the browser while on the About page, to display the page using server rendering. Try to create a new issue by clicking on the Create Issue menu item (+ icon) in the navigation bar. What happens? Can you explain this? Hint: (a) Try to put a breakpoint in showModal() method in IssueAddNavItem and then (b) inspect the + icon using the browser’s Developer Tools. Check out the event listeners attached to it. Try these after clicking on the Home menu and note the difference.
2.
Press refresh on the browser while on the About page, to display the page using server rendering. Use the Developer Tools to inspect the network calls and then navigate to any other page, say the Issue List page. Do the same by starting at the Report page rather than the About page. What differences do you see, and why?
Answers are available at the end of the chapter.
Hydrate
Although the page looks as it is supposed to now, there is still a problem with it. If you tried the exercise at the end of the previous section, you will realize that what was rendered was pure HTML markup, without any JavaScript code or event handlers. Thus, there is no user interaction possible in the page.
In order to attach event handlers, we have to include the source code and let React take control of the rendered page. The way to do this is by loading React and letting it render the page as it would have done during browser rendering using the ReactDOM.render(). Since we have not included the JavaScript bundles in the template, this is not being called and therefore, React did not get control of the page. So let’s add the scripts to the served page, just as in index.html, and see what happens. The changes to the template are shown in Listing 12-25.
...
<body>
<!-- Page generated from template. -->
<div id="contents">${body}</div>
<script src="/env.js"></script>
<script src="/vendor.bundle.js"></script>
<script src="/app.bundle.js"></script>
</body>
...
Listing 12-25
ui/server/template.js: Include Browser Bundles
Now, if you test the application by refreshing the About page, you’ll find that the + button works! This means event handlers have been attached. But you’ll also see a warning on the console to this effect:
Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.
So, React makes a distinction between rendering the DOM to replace a DOM element and attaching event handlers to the server-rendered DOM. Let’s change render() to hydrate() as recommended by the warning. This change to App.jsx is shown in Listing 12-26.
When testing with this change, you will find that the warning has disappeared and that all event handlers have been installed. You can see the effect not only when you click on the + button, but also when navigating to other tabs in the navigation bar. Earlier, these would have caused browser refreshes whereas now these navigations will load the appropriate component into the DOM directly, with React Router doing its magic.
This step completes the server rendering sequence of events, and the sequence diagram for server rendering needs this last step for the sake of completion. The new sequence diagram is shown in Figure 12-5.
Figure 12-5
Server rendering sequence diagram updated with Hydrate
The changes to the diagram are outlined in the following steps:
1.
Rather than the plain About component, the server returns script tags for the application and React and other library source code bundles.
2.
The About page is viewable, but not interactive. There is no real change in the execution here, only that the diagram explicitly states that the page is not interactive.
3.
The browser fetches the JavaScript bundles and executes them. As part of this, ReactDOM.hydrate() is executed with the routed page as the root element.
4.
ReactDOM.hydrate() causes event handlers to be attached to all components, and now the page is viewable and interactive.
Data from API
We used a hard-coded message in the About component to get off the ground. In reality, this string should come from the API server. Specifically, the about API’s result should be displayed in place of the hard-coded version string for the API.
If we were to follow the same pattern as the other components that loaded up data from the API, we would have implemented the data fetching in the lifecycle method componentDidMount() and set the state of the component. But in this case, we really need the API’s return value to be available when the component is being rendered on the server.
This means that we’ll need to be able to make requests to the API server via graphQLFetch() from the server as well. All this while this function assumed that it was being called from the browser. This needs a change. Firstly, we’ll need to replace the whatwg-fetch module with something that can be used both on the browser as well as Node.js. The package called isomorphic-fetch is what we’ll use to achieve this. So let’s replace the package.
$ cd ui
$ npm uninstall whatwg-fetch
$ npm install isomorphic-fetch@2
Now, we can replace the import whatwg-fetch with isomorphic-fetch. But the import is currently within App.jsx, which is browser specific. Let’s remove it there and add isomorphic-fetch where it is really required: graphQLFetch.js. The change to App.jsx is shown in Listing 12-27.
...
import 'whatwg-fetch';
...
Listing 12-27
ui/browser/App.jsx: Removal of whatwg-fetch Import
In graphQLFetch.js, we currently have the API endpoint specification in window.ENV.UI_API_ENDPOINT. This will not work on the server because there is no variable called window. We’ll need to use process.env variables. But we don’t have anything to indicate whether the function is being called in the browser or in Node.js. Webpack’s plugin called DefinePlugin can be used to define global variables that are available at runtime. We had discussed this plugin briefly at the end of Chapter 8, “Modularization and Webpack,” but did not use it. Let’s now use this plugin to define a variable called __isBrowser__ that is set to true in a browser bundle, but false in the server bundle. The changes for defining this variable in webpack.config.js are shown in Listing 12-28.
...
const webpack = require('webpack');
...
const browserConfig = {
...
plugins: [
new webpack.DefinePlugin({
__isBrowser__: 'true',
}),
],
devtool: 'source-map',
};
const serverConfig = {
...
plugins: [
new webpack.DefinePlugin({
__isBrowser__: 'false',
}),
],
devtool: 'source-map',
};
Listing 12-28
ui/webpack.config.js: DefinePlugin and Setting __isBrowser__
In the uiserver.js file, let’s set up the variable in process.env if it’s not set already. This is so that other modules can get this configuration variable without having to worry about the default value. Further, in the proxy mode of operation, the browser and the server API endpoints need to be different. The browser would need to use the UI server for the APIs, which will get proxied to the API server, whereas the server needs to call the API server directly. Let’s introduce a new environment variable for the API endpoint for the UI server, called UI_SERVER_API_ENDPOINT. We can default this to the same endpoint as the UI_API_ENDPOINT if it was not specified. The changes for this are shown in Listing 12-29.
ui/server/uiserver.js: Set process.env Variable If Not Set
You could add the new environment variable to your .env file, but since we’re using the non-proxy mode of operation, you can leave it commented out. The same change is shown in sample.env in Listing 12-30.
ui/sample.env: Addition of Environment Variable for API Endpoint for Use by the UI Server
Now, we can change graphQLFetch.js to get the correct API endpoint from process.env or from window.ENV depending on whether it’s being run on Node.js or on the browser. The changes to this file are shown in Listing 12-31.
ui/src/graphQLFetch.js: Using Isomorphic-Fetch and Conditional Configuration
Now, we are in a position to call graphQLFetch() from the server. Just before calling renderToString(), we can make a call to get the data from the About API like this:
But then, how do we pass this information down to the About component while it’s being rendered? One way to do this is by passing it to the Page component as props, which in turn can pass it along to the Contents component, and then finally to About. But this is a hindrance and causes excess coupling between components—neither Page nor Contents need to know about the data that is of relevance only to About.
The solution to this is to use a globalstore for all data that is needed for the hierarchy of components that need to be rendered. Let’s create this store as a module in the shared directory, in a file called store.js. The implementation of this store is simple: just an empty object that is exported. The users of this module can just assign key values that will be available globally by importing this module. The contents of the new file are shown in Listing 12-32.
const store = {};
export default store;
Listing 12-32
ui/src/store.js: New Global Generic Storage Module (Complete Source)
Now, the results of the API call can be saved in this store. This change to render.jsx, and the call to graphQLFetch() for getting the initial data, are shown in Listing 12-33.
import template from './template.js';
import graphQLFetch from '../src/graphQLFetch.js';
ui/src/render.jsx: Changes for Saving the Data from an API Call
With the data being available in the global store, we can now change the About component to read it off the global store to display the real API version. Let’s also guard this by checking if the store exists; this could be useful when constructing the same component in the browser. This change to About.jsx is shown in Listing 12-34.
ui/src/About.jsx: Use Version Obtained from the API Call Via a Global Store
If you test this, you’ll be surprised to find that the About page shows the API version as “unknown” instead of the value fetched from the API. Do take a look at the page’s source (use Developer Tools to inspect the page source), and you will find that the HTML indeed has the API version string from the server. Then, why does it not show up in the screen?
If you look at the Developer Console, you’ll see an error message like this:
Warning: Text content did not match. Server: "Issue Tracker API v1.0" Client: "unknown"
That should give you a hint as to the underlying problem. We’ll address this issue in the next section.
Syncing Initial Data
The error message in the previous section says that there was a difference between the DOM that ReactDOM.hydrate() generated and what was rendered by the server. From the server, we used the result of the API call to set the version, but when React tried to attach event handlers using hydrate() on the browser, it did not find any value in the store, and thus the error. Here’s a quote from the React documentation:
React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them. In development mode, React warns about mismatches during hydration. There are no guarantees that attribute differences “patched up in case of mismatches. This is important for performance reasons because in most apps, mismatches are rare, and so validating all markup would be prohibitively expensive.
If you think about it, it makes a lot of sense. When hydrating (or attaching event handlers to the DOM), things can get ambiguous if the tree generated by the hydrate() call doesn’t match the tree that is already there, rendered by the server. Note that hydrate() is just a variation of render()—it really creates a virtual DOM with event handlers that can be synced to the actual DOM.
What is needed is to make the browser render identical to the server render. But this requires that the same data be used to render the component on the server as well as the browser. An API call during the browser render (for example, in the lifecycle method componentDidMount()) will not cut it because it is asynchronous. We need the data when the component is being rendered for the first time.
The recommended way to do this is to pass the same initial data resulting from the API call to the browser in the form of a script and use that to initialize the global store. This way, when the component is being rendered, it will have the same data as the component that was rendered at the server.
The first thing to do is to change the template so that it takes an additional argument (the initial data) and sets it in a global variable in a <script> section. Since we’ve to convert any object to a string representation that can be valid JavaScript, let’s use JSON.stringify() to convert the data to a string. Let’s call this global variable __INITIAL_DATA__. The double-underscore signifies that this is a special global variable and that it is unlikely to collide with any other global variable because of other modules. The changes to template.js are shown in Listing 12-35.
ui/server/template.js: Include Initial Data as a Script
At the time of rendering at the server, we can now pass the initial data to the browser via this template, the same data that was used in the rendering of the page on the server. The changes for this in render.jsx are shown in Listing 12-36.
...
async function render(req, res) {
...
const body = ReactDOMServer.renderToString(element);
res.send(template(body, initialData));
}
...
Listing 12-36
ui/server/render.jsx: Changes for Sending Initial Data to the Browser
At the browser, we’ll need to set this value to the global store before anything else, so that when the component is rendered, it has access to the initial data in the global store. We can do this global store initialization where the browser rendering starts, in App.jsx. This change is shown in Listing 12-37.
...
import Page from '../src/Page.jsx';
import store from '../src/store.js';
// eslint-disable-next-line no-underscore-dangle
store.initialData = window.__INITIAL_DATA__;
...
Listing 12-37
ui/browser/App.jsx: Use Initial Data to Initialize the Store
Now, when the component is rendered in the browser, it will have the same initial data in the store as it did when it was rendered on the server. If you test the application by refreshing the browser while in the About page, you will find that the React error message is no longer shown. This indicates that the result of the browser rendering matched the server rendering, allowing React to attach event handlers without any mismatches.
The other pages will still show an error, for example, the /issues URL will throw the following error in the console:
Warning: Expected server HTML to contain a matching <div> in <div>.
The original index.html is being returned for the URL /issues, which just has an empty <div> in the body, because it was not server-rendered like in the case of the About page. When React renders the DOM in the browser during the call to hydrate(), the actual page is rendered. Thus, there is a mismatch in what the server rendered and what the browser rendered, and therefore the error. We’ll address this in later sections in this chapter, when we synchronize the server and browser data for all pages in a generic manner.
Common Data Fetcher
At this point, although refreshing the browser with the URL pointing at /about works well, you’ll find that navigating to the About page after starting from any other page, say /issues, does not show the API version from the API server. That’s because we never added a data fetcher in the About component that could be used to populate its message to take care of the case where it is mounted only on the browser.
So, just like for the other components, let’s add a componentDidMount() method in the About component. It will now need to be converted to a regular component from a stateless function. Let’s use a state variable to store and display the API version. We’ll call this variable apiAbout. Let’s initialize this variable in the constructor from the global store, if it has the initial data.
This will set the state variable to null if the initial data was missing, as would be the case when the /issues page was loaded and the user navigated to the About page. We could use this fact to initiate an API call within componentDidMount(). But since we have the same API call being made in render.jsx, let’s use a common function to fetch the data that can be shared by the About component as well as by the render.jsx file. The best place for this is in the About component itself, as a static function.
...
static async fetchData() {
const data = await graphQLFetch('query {about}');
return data;
}
...
Now, in componentDidMount(), data can be fetched and set in the state if the state variable apiAbout has not been initialized by the constructor.
...
async componentDidMount() {
const { apiAbout } = this.state;
if (apiAbout == null) {
const data = await About.fetchData();
this.setState({ apiAbout: data.about });
}
}
...
Finally, in the render() method, we can use the state variable rather than the variable from the store. The complete source code of About.jsx after all these changes is shown in Listing 12-38.
import React from 'react';
import store from './store.js';
import graphQLFetch from './graphQLFetch.js';
export default class About extends React.Component {
ui/server/render.jsx: Use Common Data Fetcher from About.jsx
After this change, you can test the application, in particular, load the home page or /issues and then navigate to the About page. You should see the correct API version being shown. You can also confirm that a call to the API is being made by inspecting the Network tab of the Developer Tools.
Generated Routes
In this section, we’ll fix the mismatch errors that React is showing for the rest of the pages. We’ll also lay down the framework that deals with fetching data in a generic manner, so that we can remove the call to About.fetchData() in render.jsx, and make it fetch data that is appropriate for the component that will actually be rendered within the page.
First, instead of returning index.html, let’s return the server-rendered HTML using the template for all the pages. The change for this is in the Express route that deals with the path /about. Let’s replace this with a * to indicate any path should return the templated HTML, rather than the file index.html from the public directory. This change is shown in Listing 12-40.
...
import path from 'path';
...
app.get('/about', (req, res, next) => {
app.get('*', (req, res, next) => {
render(req, res, next);
});
app.get('*', (req, res) => {
res.sendFile(path.resolve('public/index.html'));
});
Listing 12-40
ui/server/uiserver.js: Return Templated HTML for Any Path
Since index.html is no longer required, we can remove this file.
$ cd ui
$ rm public/index.html
This change will need a restart of the server since HMR does not handle changes to uiserver.js itself. On testing the application, you will find that the React error for mismatched <div> for all the pages is no longer seen. If you inspect the page source, you will find that the server returns a full page with the navigation bar, etc., but without the data.
For example, when you refresh the page /issues, you will see that the table header is present, but the table itself is not populated with issues. It matches the browser rendering because even in the browser, the initial render starts with an empty set of issues. Only during componentDidMount() is the list of issues fetched from the API and populated in the table. We’ll address this in the following sections. For now, let’s ensure that we have the ability to determine what data needs to be fetched based on the matching route.
The main problem we need to solve is that the data required via API calls needs to be available before rendering is initiated on the server. The only way this can be done is by keeping a common source of truth for the list of routes available. Then, we could match the request’s URL against each route and figure out which component (and therefore, which fetchData() method) will match. The same source of truth should also be responsible for generating the actual <Route> components during a render.
Let’s keep this list of routable pages in a JavaScript array in a new file called routes.js. This can be a simple array with the path to the route and the component that needs to be rendered if the route is matched with the URL. This new file is shown in Listing 12-41.
import IssueList from './IssueList.jsx';
import IssueReport from './IssueReport.jsx';
import IssueEdit from './IssueEdit.jsx';
import About from './About.jsx';
import NotFound from './NotFound.jsx';
const routes = [
{ path: '/issues', component: IssueList },
{ path: '/edit/:id', component: IssueEdit },
{ path: '/report', component: IssueReport },
{ path: '/about', component: About },
{ path: '*', component: NotFound },
];
export default routes;
Listing 12-41
ui/src/routes.js: New File to Store Route Metadata
We’ve imported and used NotFound as a component, but this is defined as part of Contents.jsx, and that won’t work. Let’s separate it and create a new file for it, as shown in Listing 12-42.
import React from 'react';
function NotFound() {
return <h1>Page Not Found</h1>;
}
export default NotFound;
Listing 12-42
ui/src/NotFound.jsx: New File for the Page Not Found Component
We can now modify Contents.jsx to generate the <Route> components from this array of routes metadata. Let’s just map the array and return a <Route> for each component, with the attributes the same as the properties of each object in the array. React also needs a unique key for every element in an array, and this can be the path of the route since that has to be unique. The changes to this file are shown in Listing 12-43.
ui/src/Contents.jsx: Changes to Generate Routes from routes.js Array
While rendering, both on the server as well as the browser, one of the routes will be chosen for rendering based on the URL. On the browser, the history object of BrowserRouter will supply the URL for matching, and on the server, we’ve supplied it via the location property of the StaticRouter, which we used to wrap the page.
We still need to replace the call to About.fetchData() with something more generic. To do that, we need to determine which of the components would match the current URL that is passed in via the request object in render.jsx. React Router exposes a function called matchPath(), which is meant exactly for this purpose: it matches a given JavaScript object, which is a route specification as in the array in routes.js like this:
...
const match = matchPath(urlPath, routeObject)
...
The routeObject object is expected to contain the properties path, exact, and strict, just as you would define a <Route> component. It returns a match object if the route matches the supplied urlPath. Thus, we can iterate over the array of routes from routes.js and find the matching route.
...
import routes from '../src/routes.js';
import { StaticRouter, matchPath } from 'react-router-dom';
...
const activeRoute = routes.find(
route => matchPath(req.path, route),
);
...
If there was a match, we can peek into the matched route object’s component property and see if there is a static function defined in the component to fetch data for it. If there is one, we can call that function to get the initial data.
...
let initialData;
if (activeRoute && activeRoute.component.fetchData) {
ui/server/render.jsx: Changes for Fetching the Initial Data Depending on the Matched Route
With these changes, we have managed to get rid of the hard-coding of the data that needs to be fetched based on the component that will be rendered for the matched route. But we’ve not yet implemented the fetching of data in many of the components. For example, refreshing the browser on /issues will continue to render an empty table that’s later filled with the list of issues fetched from an API call in the browser. This is not what is needed: a request to /issues should result in a page replete with the list of issues that match the filter. But there are nuances that are different from what we did for the About component: these API calls vary depending on parameters. As we set out to implement the data fetcher in each of the existing components, we’ll explore how these parameters can be passed through for use in rendering.
Data Fetcher with Parameters
In this section, we’ll make the IssueEdit component render from the server with the data that it requires prepopulated.
To start, let’s separate the data fetcher into a static method as we had done in the About component. This method relies on the ID of the issue to fetch the data. The most generic entity that has this information is the match object that the component has access to automatically while rendering on the browser.
While rendering on the server, the result of the matchPath() call gives us the same information. So, let’s change the prototype of the fetchData() function to include the match. Further, since the method for showing errors on the browser and the server is different, let’s also take the function to show errors, showError, as an argument. And then, let’s move the GraphQL query to this function from the loadData() function and execute it with the issue’s ID obtained from the match object.
...
static async fetchData(match, showError) {
const query = `...`;
const { params: { id } } = match;
const result = await graphQLFetch(query, { id }, showError);
return result;
}
...
As part of the constructor, we can check if there is any initial data and use that to initialize the state variable issue. Further, instead of having an empty issue object, let’s set the state variable to null to indicate that it was not preloaded from the server. Now that we have multiple components looking at initial data, it is possible that the constructor of a component rendered on the browser confuses the initial data to be meant for itself. So, let’s delete the data from the store once we’ve consumed it.
In the componentDidMount() method, we can now look for the presence of the state variable. If it is not null, it means that it was rendered from the server. If it’s null, it means that the user navigated to this component in the browser from a different page that was loaded from the server. In this case, we can load the data using fetchData().
...
componentDidMount() {
const { issue } = this.state;
if (issue == null) this.loadData();
}
...
In the loadData() method, we’ll replace the original call to graphQLfetch() with a call to fetchData().
...
async loadData() {
const { match } = this.props;
const data = await IssueEdit.fetchData(match, this.showError);
this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
}
...
Now that we’re setting the issue object value to null to indicate a preinitialized state, let’s return null in the render() method if the issue state variable is null.
...
render() {
const { issue } = this.state;
if (issue == null) return null;
const { issue: { id } } = this.state;
...
}
...
The complete changes to the IssueEdit component are shown in Listing 12-45.
...
import Toast from './Toast.jsx';
import store from './store.js';
...
export default class IssueEdit extends React.Component {
static async fetchData(match, showError) {
const query = `query issue($id: Int!) {
issue(id: $id) {
id title status owner
effort created due description
}
}`;
const { params: { id } } = match;
const result = await graphQLFetch(query, { id }, showError);
const data = await graphQLFetch(query, { id }, this.showError);
const { match } = this.props;
const data = await IssueEdit.fetchData(match, this.showError);
this.setState({ issue: data ? data.issue : {}, invalidFields: {} });
}
...
render() {
const { issue } = this.state;
if (issue == null) return null;
...
}
...
}
...
Listing 12-45
ui/src/IssueEdit.jsx: Changes to Use a Common Data Fetcher
Since we also have the About component creating the initial data, let’s delete this data after using it, as we have done in IssueEdit. The changes for this are shown in Listing 12-46.
ui/src/About.jsx: Add Deletion of initialData After Consumption
The next step is to pass in the match parameter at the time of server rendering, in render.jsx. We could save the result of matchPath() during the find() routine, but I’ve chosen to reevaluate this value after matching. The changes to render.jsx are shown in Listing 12-47.
...
if (activeRoute && activeRoute.component.fetchData) {
ui/server/render.jsx: Changes to Include the Match Object in a Call to fetchData()
At this point, if you navigate to the Edit page of any issue and refresh the browser, you will find errors in the Developer Console like this:
Uncaught TypeError: created.toDateString is not a function
This is because we wrote out the contents of the issue object using JSON.stringify(), which converts dates to strings. When we made an API call via graphQLFetch, we used a JSON date reviver function to convert strings to date objects, but the script that initializes the data does not use JSON.parse(). The script is executed as is. You can take a look at the source file using View Page Source in your browser, and you’ll find that the key created is set to a string.
The solution is to serialize the contents of the initialize data so that it is proper JavaScript (and the property created will be assigned with new Date(...)). To do this, there is a package called serialize-javascript, which we can install now:
$ cd ui
$ npm install serialize-javascript@1
Now, let’s replace JSON.stringify() in template.js with the serialize function. The changes for this are shown in Listing 12-48.
ui/server/template.js: Use Serialize Instead of Stringify
If you test the application after this change, you will find that the error message is gone, and the Issue Edit page shows the issue properly, including the date fields. Further, if you navigate to the About page, you should see that it loads the API version from the API server (the Network tab of the Developer Tools will show this). This proves that the initial data for one page does not affect the rendering of another page.
Exercise: Data Fetcher With Parameters
1.
Is there a way to use the JSON date reviver strategy for the initial data also? How about the reverse: Is there a way to use serialize() instead of JSON.parse() for the API calls? Should we do it?
Answers are available at the end of the chapter
Data Fetcher with Search
In this section, we’ll implement the data fetcher in the IssueList component. In the IssueEdit component, we dealt with the fact that the data fetcher needed the parameters of the matching route. In IssueList, we’ll deal with the fact that the search (query) string part of the URL is needed for fetching the correct set of issues.
Let’s pass the query string (React Router calls this search) in addition to the match object to fetchData(). In the server, we don’t have direct access to this, so we’ll have to search for the ? character and use a substring operation on the request’s URL to get this value. The changes for this in render.jsx are shown in Listing 12-49.
...
if (activeRoute && activeRoute.component.fetchData) {
const match = matchPath(req.path, activeRoute);
const index = req.url.indexOf('?');
const search = index !== -1 ? req.url.substr(index) : null;
ui/server/render.jsx: Include Search String in fetchData() Calls
Due to this change, we’ll also have to modify IssueEdit to include this new argument in its static fetchData() method. The changes for this are shown in Listing 12-50.
...
export default class IssueEdit extends React.Component {
const data = await IssueEdit.fetchData(match, null, this.showError);
...
}
...
}
...
Listing 12-50
ui/src/IssueEdit: Changes for Change in fetchData() Prototype
Now, let’s create the data fetcher in the IssueList component. We’ll move the required piece of code from the loadData() method into this new static method. The following changes are in comparison to the original code in the loadData() method. For a full listing of this method, refer to Listing 12-51.
const data = await graphQLFetch(query, vars, this.showError);
return data;
}
...
The loadData() method will now use this data fetcher instead of making the query directly. The code that has moved to fetchData() is not shown as deleted explicitly; this is the complete code for this method.
...
async loadData() {
const { location: { search } } = this.props;
const data = await IssueList.fetchData(null, search, this.showError);
if (data) {
this.setState({ issues: data.issueList });
}
}
...
In the constructor, we’ll use the store and the initial data to set the initial set of issues and delete it once we have consumed it.
When the component is mounted, we can avoid loading the data if the state variable has a valid set of issues, i.e., it is not null.
...
componentDidMount() {
const { issues } = this.state;
if (issues == null) this.loadData();
}
...
Finally, as in the IssueEdit component, let’s skip rendering if the state variable issues is set to null. The complete set of changes to this component, including this last change, are shown in Listing 12-51.
...
import Toast from './Toast.jsx';
import store from './store.js';
...
export default class IssueList extends React.Component {
const data = await graphQLFetch(query, vars, this.showError);
const data = await IssueList.fetchData(null, search, this.showError);
if (data) {
this.setState({ issues: data.issueList });
}
}
...
render() {
const { issues } = this.state;
if (issues == null) return null;
...
}
...
}
Listing 12-51
ui/src/IssueList.jsx: Changes for Data Fetcher Using Search
If you test the application, especially the Issue List page now, you should find that a refresh on the Issue List shows the issues filled in the table. This can be confirmed by inspecting the source of the page: you should find that the table is prefilled. Also, if you watch the Network tab of the Developer Tools, you should not see any API call being made to fetch the list of issues on a refresh, whereas the API call will be made when navigating from any other page. Do also test with different values of filters to ensure that the search string is being used correctly.
Nested Components
We still have one more component to deal with: IssueDetail. At this juncture, the component will seem to work, both when clicking on an issue row in the list and when the browser is refreshed with the URL containing an issue ID such as /issues/1. But you’ll find that the detail is being fetched after mounting the component and not as part of the server rendered HTML. As discussed, this is not good. We really need the detail part also to be rendered in the server.
Although React Router’s dynamic routing works great when navigating via links in the UI, it is quite inconvenient when it comes to server rendering. We cannot easily deal with nested routes. One option is to add nesting of routes in routes.js and pass the nested route object to the containing component so that it can create a <Route> component at the appropriate place based on this.
Another alternative is the one we discussed in the “Nested Routes” exercise in Chapter 9. In this alternative, the route specification for IssueList includes an optional Issue ID, and this component deals with the loading of the detail part too. This has the following advantages:
The route specification remains simple and has only the top-level pages in a flat structure, without any hierarchy.
It gives us an opportunity to combine two API calls into one in the case where the Issue List is loaded with a selected issue.
Let’s choose this alternative and modify the component IssueList to render its contained detail as well. This will cause the IssueDetail component to be greatly simplified, reducing it to a stateless component, which only renders the detail. The complete code for the new IssueDetail component is shown in Listing 12-52.
import React from 'react';
export default function IssueDetail({ issue }) {
if (issue) {
return (
<div>
<h3>Description</h3>
<pre>{issue.description}</pre>
</div>
);
}
return null;
}
Listing 12-52
ui/src/IssueDetail.jsx: Replaced Contents with a Stateless Component
Let’s also modify the route to specify the ID parameter, which is optional. The way to specify that the parameter is optional is by appending a ? to it. The changes to routes.js are shown in Listing 12-53.
...
const routes = [
{ path: '/issues/:id?', component: IssueList },
...
];
...
Listing 12-53
ui/src/routes.js: Modification of /issues Route to Include an Optional Parameter
Now, in the IssueList component, we’ll find the ID of the selected issue in props.match. Also, in fetchData(), we’ll use the match object to find the ID of the selected issue and use it. If there exists a selected ID, we’ll fetch its details along with the issue list in a single GraphQL call. GraphQL allows adding multiple named queries within a query, so we’ll take advantage of this. But since the second call for the issue’s details is optional, we’ll have to execute this conditionally. We can use the @include directive of GraphQL to achieve this. We’ll pass an extra variable hasSelection, and we’ll include the second query if the value of this is true.
const data = await graphQLFetch(query, vars, showError);
return data;
}
...
Now, the returned data object will have two properties in it when hasSelection is set to true: issueList and issue. The same will appear in store.initialData, so let’s use the additional issue object to set the initial state in the constructor.
...
constructor() {
...
const selectedIssue = store.initialData
? store.initialData.issue
: null;
delete store.initialData;
this.state = {
issues,
selectedIssue,
...
};
...
We need to make similar changes to loadData(): pass the match to fetchData(), then use the result to set the state variable selectedIssue in addition to issues.
...
async loadData() {
const { location: { search }, match } = this.props;
const data = await IssueList.fetchData(nullmatch, search, this.showError);
At this point, refreshing the issue list and changing the filter will work, but selecting a new issue row will not reflect the changes in the detail section. That’s because componentDidUpdate() checks only for changes in the search and reloads the data. We need also to check for changes in the ID of the selected issue to reload.
ui/src/IssueList.jsx: Pull Up IssueDetail Into IssueList
If you test the Issue List page now, especially with any one issue selected and refreshing the browser, you’ll find the detail of the selected issue loaded along with the list of issues. If you change the selected issue by clicking on another row, you will see in the Network tab of the Developer Tools that a single GraphQL call is fetching both the list of issues as well as the details of the selected issue.
Exercise: Nested Components
1.
When changing the selected issue, although it’s a single GraphQL call, the entire issue list is being fetched. This is not required and adds to the network traffic. How would you optimize for this?
Answers are available at the end of the chapter
Redirects
We still have one last thing to take care of: a request to the home page, that is, /, returns an HTML from the server that contains an empty page. It seems to work because after rendering on the browser, React Router does a redirect to /issuesin the browser history. What we really need is for the server itself to respond with a 301 Redirect so that the browser fetches /issues instead from the server. This way, search engine bots also will get the same contents for a request to / as they get for /issues.
React Router’s StaticRouter handles this by setting a variable called url in any context that is passed to it. We’ve been passing an empty, unnamed context to StaticRouter. Instead, let’s pass a named object, though empty, to it. After rendering to string, if the url property is set in this object, it means that the router matched a redirect to this URL. All we need to do is send a redirect in the response instead of the templated response.
The changes to render.jsx to handle the redirect are shown in Listing 12-55.
const body = ReactDOMServer.renderToString(element);
if (context.url) {
res.redirect(301, context.url);
} else {
res.send(template(body, initialData));
}
...
Listing 12-55
ui/server/render.jsx: Handle Redirects on the Server
Now, if you enter http://localhost:8000/ in your browser, you should see that the Issue List page loads without any flicker. In the Network tab, you will find the very first request resulting in a redirect to /issues, which then follows the regular path of rendering the issue list at the server.
Summary
This chapter may have been a little heavy since we used complex constructs and patterns to implement server rendering. Hopefully, using the About page eased the complexity and helped you understand the fundamental concepts for server rendering.
It must also be evident by now that React itself, not being a framework, does not dictate each of the additional parts that complete the application. React Router helped us a bit with front-end routing, but some of it did not work for server rendering. We had to invent our own patterns of generated routes and that of data fetchers as static methods in each of the routed components to deal with data associated with server rendered pages.
As we move on to the next chapter, we won’t focus on one single feature or concept. Instead we’ll implement features that are common to many applications. As we do this, we’ll see how the MERN stack satisfies the needs of these advanced features.
Answers to Exercises
Exercise: Basic Server Rendering
1.
The ReactDOMServer method renderToNodeStream() can be considered to replace renderToString(). This method returns a stream that can be piped to the Express response stream. Instead of a template, we’ll need pre- and a post-body strings that we can write to the response before and after piping the node stream, respectively.
Exercise: Server Router
1.
When the About page is rendered using server rendering, you will find that clicking on the + menu item does nothing. There are no event handlers attached to the menu item, further, you will find that there is no code where you can put a breakpoint. The reason is that the template does not include the JavaScript bundles that contained both the component as well as React library code.
2.
In a browser-rendered navigation bar, clicking on a link does not load the page from the server. Only XHR calls are made to get the data and the DOM is constructed on the browser. In a server-rendered navigation bar, clicking on a link loads the page from the server, just like a normal href would have done. The reason is the same as in the previous exercise: in a server-rendered page, there are no event handlers attached that trap the click event and make changes on the DOM within the browser. In a server-rendered page, the links behave as pure href links do: they make the browser load a new page.
Exercise: Data Fetcher with Parameters
1.
You could use JSON.parse(), but it needs a string as its argument. Since the string representation of the initial data itself has a lot of double quotes, they need to be escaped, or you could use single quotes around it. Another strategy used by some is to use a hidden textarea or a div to store the string and read it off the DOM and call JSON.parse() on that string. I find that serialize is a much more concise and clearer option.
As for the reverse, the caller of the API will need to use an eval() instead of JSON.parse() on the resulting data. This is quite dangerous because it would allow new functions to be installed as a result, if the data contained any. If the API server has somehow been compromised, this can enable malicious code to be injected into the browser. Further, this strategy assumes that the caller works on JavaScript, and this may not be a valid assumption.
Exercise: Nested Components
1.
A good strategy to optimize the data being fetched is to write another method that fetches the selected issue alone via a different GraphQL query. This method, say loadSelectedIssue(), can be called from componentDidUpdate() if the search did not change but the ID parameter changed.