Progressive Web Apps with React.js: Part 4 — Progressive Enhancement

Part 4 of a new series walking through tips for shipping mobile web apps optimized using Lighthouse. This issue, we’ll be looking at progressive enhancement via server-side rendering.

Progressive Enhancement

A comparison of rendering strategies for ReactHN. It’s important to note YMMV — server-side rendering HTML for an entire view may make sense for content-heavy sites but this comes at a cost. On repeat visits, client-side rendering with an application shell architecture that is cached locally might perform better. Measure what makes sense for you.
If your PWA is progressively enhanced and contains content when scripts are unavailable, Lighthouse will give you the all clear.

Universal rendering

The Selio Progressive Web App uses Universal rendering to ship a static version of their experience that works without JS if the network is taking time to load it up but can hydrate to improve the experience once all scripts are loaded.

Universal Rendering with React Router

// server.js
import express from 'express';
import React from 'react';
import fs from 'fs';
import { renderToString } from 'react-dom/server';
import HackerNewsApp from './app/HackerNewsApp';
const app = express();
app.set('views', './');
app.set('view engine', 'ejs');
app.use(express.static(__dirname + '/public'));
const stories = JSON.parse(fs.readFileSync(__dirname + '/public/stories.json', 'utf8'));
const HackerNewsFactory = React.createFactory(HackerNewsApp);
app.get('/', (request, response) => {
const instanceOfComponent = HackerNewsFactory({ data: stories });
response.render('index', {
content: renderToString(instanceOfComponent)
});
});

Universal mounting

<! — index.html →
<div id=”container”><%- content %></div>
<script type=”application/json” id=”bootupData”>
<% reactBootupData %>
</script>
<script src=”bundle.js”></script>
// ...
const stories = JSON.parse(fs.readFileSync(__dirname + '/public/stories.json', 'utf8'));
const HackerNewsFactory = React.createFactory(HackerNewsApp);
app.get('/', (request, response) => {
const instanceOfComponent = HackerNewsFactory({ data: stories });
response.render('index', {
reactBootupData: JSON.stringify(stories),
content: renderToString(instanceOfComponent)
});
});
import React from 'react';
import { render } from 'react-dom';
import HackerNewsApp from './app/HackerNewsApp';
let bootupData = document.getElementById('bootupData').textContent;
if (bootupData !== undefined) {
bootupData = JSON.parse(bootupData);
}
render(<HackerNewsApp data={bootupData} />, document.getElementById('container'));

Universal Data-fetching

// Fetch for Node and the browser
import fetch from 'isomorphic-fetch';
// ...
class HackerNewsApp extends Component {
constructor() {
super(...arguments);
this.state = {
stories: this.props.data || []
}
},
componentDidMount() {
if (!this.props.data) {
HackerNewsApp.fetchData().then( stories => {
this.setState({ stories });
})
}
},
render() {
// ...
}
}
// ...
HackerNewsApp.propTypes = {
data: PropTypes.any
}
HackerNewsApp.fetchData = () => {
return fetch('http://localhost:8080/stories.json')
.then((response => response.json()));
};
export default HackerNewsApp;
import express from "express";
import fs from 'fs';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { match, RouterContext } from 'react-router';
import routes from './app/routes';
const app = express();app.set('views', './');
app.set('view engine', 'ejs');
app.use(express.static(__dirname + '/public'));
const stories = JSON.parse(fs.readFileSync(__dirname + '/public/stories.json', 'utf8'));// Helper function: Loop through all components in the renderProps object
// and returns a new object with the desired key
let getPropsFromRoute = ({routes}, componentProps) => {
let props = {};
let lastRoute = routes[routes.length - 1];
routes.reduceRight((prevRoute, currRoute) => {
componentProps.forEach(componentProp => {
if (!props[componentProp] && currRoute.component[componentProp]) {
props[componentProp] = currRoute.component[componentProp];
}
});
}, lastRoute);
return props;
};
let renderRoute = (response, renderProps) => {
// Loop through renderProps object looking for ’fetchData’
let routeProps = getPropsFromRoute(renderProps, ['fetchData']);
if (routeProps.fetchData) {
// If one of the components implements ’fetchData’, invoke it.
routeProps.fetchData().then((data)=>{
// Overwrite the react-router create element function
// and pass the pre-fetched data as data/bootupData props
let handleCreateElement = (Component, props) =>(
<Component data={data} {...props} />
);
// Render the template with RouterContext and loaded data.
response.render('index',{
bootupData: JSON.stringify(data),
content: renderToString(
<RouterContext createElement={handleCreateElement} {...renderProps} />
)
});
});
} else {
// No components in this route implements ’fetchData’.
// Render the template with RouterContext and no bootupData.
response.render('index',{
bootupData: null,
content: renderToString(<RouterContext {...renderProps} />)
});
}
};
app.get('*', (request, response) => {
match({ routes, location: request.url }, (error, redirectLocation, renderProps) => {
if (error) {
response.status(500).send(error.message);
} else if (redirectLocation) {
response.redirect(302, redirectLocation.pathname + redirectLocation.search);
} else if (renderProps) {
renderRoute(response, renderProps);
} else {
response.status(404).send('Not found');
}
});
});
app.listen(3000, ()=>{
console.log("Express app listening on port 3000");
});
let handleCreateElement = (Component, props) => {
if (Component.hasOwnProperty('fetchData') {
let bootupData = document.getElementById('bootupData').textContent;
if (!bootupData == undefined) {
bootupData = JSON.parse(bootupData);
}
return <Component data={bootupData} {...props} />;
} else {
return <Component {...props} />;
}
}
render((
<Router history={createHistory()} createElement={handleCreateElement}>{routes}</Router>
), document.getElementById('container'))

Data-flow tips

Guarding against globals

// Deserialize caches from sessionStorage
loadSession() {
if (typeof window === 'undefined') return
idCache = parseJSON(window.sessionStorage.idCache, {})
itemCache = parseJSON(window.sessionStorage.itemCache, {})
}
// Serialize caches to sessionStorage as JSON
saveSession() {
if (typeof window === 'undefined') return
window.sessionStorage.idCache = JSON.stringify(idCache)
window.sessionStorage.itemCache = JSON.stringify(itemCache)
}
  "browser": {
"/path/to/component.js": "/path/to/component-browser.js"
}

Remember: interactivity is key

Server rendering is a lot like giving users a hot apple pie. It looks ready but that doesn’t mean they can interact with it.

Progressive Bootstrapping as visually illustrated by Paul Lewis

Practical implementation: ReactHN

Without JS: links point to /story/:id. With JS: links point to #/story/:id

Testing Progressive Enhancement

Chrome DevTools supports both network throttling and disabling JS via the Settings panel

Further Reading

Eng. Manager at Google working on Chrome • Passionate about making the web fast.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store