Back in 2013, Spike Brehm from Airbnb published an article in which he analyzed the shortcomings of SPA applications (Single Page Application) and, as an alternative, proposed a model of isomorphic web applications. Now the term universal web application is used more often.
In a universal web application, each page can be formed either by a web server or by JavaScript on the client side. At the same time, the source code of the programs that are executed by the web server and the client should be unified (universal) in order to eliminate inconsistency and increased development costs.
Criticism of SPA applications
What is wrong with SPA applications? And what problems arise when developing universal applications?
SPA applications are criticized, first of all, for their low search engine ranking (SEO), speed of work, and accessibility. There is evidence that React applications may not be available for screen readers.
Partially, the issue with SEO SPA-applications is solved by Prerender - a server with a “headless” client, which is implemented using chrome-remote-interface (previously used by phantomjs). You can deploy your own server with Prerender or access a public service. In the latter case, access will be free with a limit on the number of pages. The process of generating a page using Prerender tools is time-consuming - usually more than 3 seconds, which means that search engines will consider such a service not optimized in speed, and its rating will still be low.
Performance problems may not appear during the development process and become noticeable when working with low-speed Internet or on a low-power mobile device (for example, a phone or tablet with 1GB of RAM and a processor frequency of 1.2GHz). In this case, the page that “flies” may load unexpectedly long.
There are more reasons for such a slow download than usually indicated. First, let`s see how the application loads JavaScript. If there are a lot of scripts (which was typical when using require.js and amd-modules), then the loading time increased due to the overhead of connecting to the server for each of the requested files.
The solution was obvious: combine all the modules into one file (using rjs, webpack or another linker). This caused a new problem: for a web application with a rich interface and logic, when loading the first page, all JavaScript code loaded into a single file. Therefore, the current trend is code splitting.
Libraries for creating universal applications
On github.com, you can now find a large number of projects that implement the idea of a universal web application. However, all these projects have common disadvantages:
- small number of project contributors
- these are draft projects for a quick start, not a library
- projects were not updated when new versions of react.js were released
- in projects, only part of the functionality necessary for the development of a universal application is implemented.
The first successful solution was the Next.js library, which has 46,500 “stars” on github.com. To evaluate the advantages of this library, we will consider what kind of functionality you need to provide for a universal web application.
Server rendering
Libraries such as react.js, vue.js, angular.js, riot.js and others all support server-side rendering. Server rendering usually works synchronously. This means that asynchronous API requests in life cycle events will be launched, but their result will be lost. (Limited support for asynchronous server rendering is provided by riot.js)
Asynchronous data loading
In order for the results of asynchronous requests to be obtained before the start of server rendering, Next.js implements a special type of component “page”, which has an asynchronous life cycle event - static async getInitialProps ({req})
.
Passing server component state to client
As a result of the server rendering of the component, an HTML document is sent to the client, but the state of the component is lost. To transfer the state of a component, usually the web server generates a script for the client, which writes the state of the server component to the global JavaScript variable.
Creation of a component on the side of a client and its binding to an HTML document
The HTML document resulting from server-side rendering of the component contains text and does not contain components (JavaScript objects). Components must be recreated in a client and “tied” to the document without re-rendering. In react.js, the hydrate()
method is executed for this. A similar function method is in the vue.js library.
Routing
Routing on the server and on the client should also be universal. That is, the same definition of routing should work for both server and client code.
Code splitting
For each page, only the necessary JavaScript code should be loaded, and not the entire application. When moving to the next page, the missing code should be loaded - without reloading the same modules, and without extra modules.
The Next.js library successfully solves all these problems. This library is based on a very simple idea. It is proposed to introduce a new type of component - “page”, in which there is an asynchronous method static async getInitialProps ({req}). A page component is a regular React component. This type of component can be thought of as a new type in the series: “component”, “container”, “page”.
Working example
To work, we need node.js and the npm package manager. If they are not already installed, the easiest way to do this is with nvm (Node Version Manager), which is installed from the command line and does not require sudo access:
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
After installation, be sure to close and reopen the terminal to set the PATH environment variable. The list of all available versions is displayed by the command:
nvm ls-remote
Download the required version of node.js and a compatible version of the npm package manager with the command:
nvm install 8.9.4
Create a new directory (folder) and execute the command in it:
npm init
As a result, the package.json
file will be generated. Download and add the necessary dependensies of the project:
npm install --save axios next next-redux-wrapper react react-dom react-redux redux redux-logger
In the project`s root directory, create the pages
directory. This directory will contain page-type components. The path to the files inside the pages directory corresponds to the url by which these components will be available. As usual, the “magic name” index.js
is mapped to url /index and /. More complex rules for urls with wildcard are also implementable.
Create file the pages/index.js
:
import React from "react"
export default class extends React.Component {
static async getInitialProps({ req }) {
const userAgent = req ? req.headers["user-agent"] : navigator.userAgent
return { userAgent }
}
render() {
return (
Hello World {this.props.userAgent}
)
}
}
This simple component uses the main features of Next.js:
- es7 syntax is available (import, export, async, class) out of the box.
- Hot-reloading also works out of the box.
- The
static async getInitialProps ({req})
function will be executed asynchronously before rendering the component on the server or on the client — and only once. If the component is rendered on the server, thereq
parameter is passed to it. The function is called only on page-type components and is not called on nested components.
In the package.json
file, add three commands to the “scripts” attribute:
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
Start the developer server with the command:
npm run dev
To implement the transition to another page without loading the page from the server, the links are wrapped in a special Link
component. Add a dependency to page/index.js:
import Link from "next/link"
and Link component:
<Link href="/time">
<a>Click me</a>
</Link>
When you click on the link, a page with a 404 error will be displayed.
Copy the pages/index.js
file to the pages/time.js
file. In the new time.js
component, we will display the current time received asynchronously from the server. In the meantime, change the link in this component so that it leads to the main page:
<Link href="/">
<a>Back</a>
</Link>
Try several times to reload each of the pages from the server, and then go from one page to another and return back. In all cases, downloading from the server will take place with server rendering, and all subsequent transitions will be done with rendering tools on the side of the client.
On the pages/time.js
page, we will place a timer that shows the current time received from the server. This will allow you to get acquainted with asynchronous data loading during server rendering.
We use redux
to store data in the store. Asynchronous actions in redux are performed using middleware redux-thunk
. Usually (but not always), one asynchronous action has three states: START
, SUCCESS
FAILURE
. Therefore, the code for determining the asynchronous action often looks complicated.
In one issue of the redux-thunk library, a simplified version of middleware was discussed, which allows you to define all three states on one line. Unfortunately, this option was never issued to the library, so we will include it in our project as a module.
Create a new redux
directory in the root directory of the application, and in it file redux/promisedMiddlewate.js
:
export default(...args) => ({ dispatch, getState }) => (next) => (action) => {
const { promise, promised, types, ...rest } = action;
if (!promised) {
return next(action);
}
if (typeof promise !== "undefined") {
throw new Error("In promised middleware you mast not use "action.promise");
}
if (typeof promised !== "function") {
throw new Error(`In promised middleware type of "action"."promised" must be "function"`);
}
const [REQUEST, SUCCESS, FAILURE] = types;
next({ ...rest, type: REQUEST });
action.promise = promised()
.then(
data => next({ ...rest, data, type: SUCCESS }),
).catch(
error => next({ ...rest, error, type: FAILURE })
);
};
A few clarifications on how this function works. The redux midleware function has the signature (store) => (next) => (action)
. The indicator that the action is asynchronous and should be processed by this particular function is the promised
property. If this property is not defined, then processing is completed and control is transferred to the following middleware: return next (action)
. A reference to the Promise
object is saved in the action.promise
property, which allows you to “hold” the static async getInitialProps ({req, store}) asynchronous function until the asynchronous action is completed.
All that is connected with the data warehouse will be placed in the redux/store.js
file:
import { createStore, applyMiddleware } from "redux";
import logger from "redux-logger";
import axios from "axios";
import promisedMiddleware from "./promisedMiddleware";
const promised = promisedMiddleware(axios);
export const initStore = (initialState = {}) => {
const store = createStore(reducer, {...initialState}, applyMiddleware(promised, logger));
store.dispatchPromised = function(action) {
this.dispatch(action);
return action.promise;
}
return store;
}
export function getTime(){
return {
promised: () => axios.get("http://time.jsontest.com/"),
types: ["START", "SUCCESS", "FAILURE"],
};
}
export const reducer = (state = {}, action) => {
switch (action.type) {
case "START":
return state
case "SUCCESS":
return {...state, ...action.data.data}
case "FAILURE":
return Object.assign({}, state, {error: true} )
default: return state
}
}
The getTime()
action will be processed by promisedMiddleware()
. For this, the promised
property has a function that returns Promise
, and the types
property has an array of three elements containing the constants ‘START’, ‘SUCCESS’, ‘FAILURE’. The values of constants can be arbitrary, their order in the list is important.
Now it remains to apply these actions in the pages/time.js
component:
import React from "react";
import {bindActionCreators} from "redux";
import Link from "next/link";
import { initStore, getTime } from "../redux/store";
import withRedux from "next-redux-wrapper";
function mapStateToProps(state) {
return state;
}
function mapDispatchToProps(dispatch) {
return {
getTime: bindActionCreators(getTime, dispatch),
};
}
class Page extends React.Component {
static async getInitialProps({ req, store }) {
await store.dispatchPromised(getTime());
return;
}
componentDidMount() {
this.intervalHandle = setInterval(() => this.props.getTime(), 3000);
}
componentWillUnmount() {
clearInterval(this.intervalHandle);
}
render() {
return (
<div>
<div>{this.props.time}</div>
<div>
<Link href="/">
<a>Return</a>
</Link>
</div>
</div>
)
}
}
export default withRedux(initStore, mapStateToProps, mapDispatchToProps)(Page);