Universal/Isomorphic React on a Swift Backend
React’s standard flow is to start with an empty HTML template from the server, and populate the DOM entirely on the client. Letting React pre-render the initial HTML for your app on the server before the client takes over has some advantages, though: it lets slower (mobile) clients load your app much faster, and it’s good for SEO for search engines that don’t support JavaScript. Since you have to run React on the server to do this, you need a JavaScript-able backend, which is why such Universal (a.k.a. Isomorphic) apps are mostly built with Node.js backends. However, you can create universal React apps with other languages too, including Swift. In this post, I’ll walk through an example of a universal React app using Swift’s Vapor web framework.
The Client JavaScript App
Because Redux lends itself well as the basis of a universal React data model, we’ll take the Redux Counter Example as the basis of our app. I will leave out the unimportant parts in this post; you can always find the full example in the Git repository.
The main client.js
file of the Counter app fetches the state via AJAX from the server
on the /api/state
endpoint, and renders the Counter
component:
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import Counter from './Counter';
import { rootReducer } from './reducers';
function load(state) {
const store = createStore(rootReducer, state);
// Render our main component
render(
<Provider store={store}>
<Counter/>
</Provider>,
document.getElementById('root')
);
}
fetch('/api/state').then(
response => response.json().then(load),
err => console.error(err)
);
On the server side, we have a Vapor handler for /api/state
that gets the state from the database, and returns it as a JSON object:
import Vapor
// Dummy database
func getStateFromDB() -> [String: Any] {
return [ "value": 42 ]
}
let drop = Droplet()
drop.get("/api/state") { req in
return toJSON(value: getStateFromDB())!
}
For now, all we need is to serve a static index.html
at the root to
get the app running:
<!doctype html>
<html>
<head>
<title>React+Swift Test App</title>
</head>
<body>
<div id="root"></div>
<script src="/js/app.js"></script>
</body>
</html>
This gives us a regular React app, where all the rendering is dynamically happening on the client, and the server only provides the data API.
Pre-rendering the App on the Server
To pre-render our app on the server, we start by creating a helper JavaScript
render
function, which we will call from the server:
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Provider } from 'react-redux';
import { default as genericRender } from './render';
export function render(preloadedState = undefined) {
const store = createStore(rootReducer, preloadedState);
return {
html: renderToString(
<Provider store={store}>
<Counter/>
</Provider>
),
state: JSON.stringify(store.getState())
};
}
The render
function renders the same component as client.js
, except it uses
React’s renderToString
to build a string instead of injecting it in the DOM. Next
to the rendered string, we also return the state from the Redux store (which,
apart from populated default values, will be the same as preloadedState
), so
we can pass it on to the client. We can then bundle this function and all its
dependencies (including React and Redux) up into a single server.js
JavaScript file using webpack.
To run this code on the server, we can use Swift’s built-in
JavaScriptCore
1 framework to load the server.js
bundle,
and call the render
function with the data we get from the database.
func loadJS() -> JSValue? {
let context = JSContext()
do {
let js = try String(
contentsOfFile: "server.js",
encoding: String.Encoding.utf8)
context?.evaluateScript(js)
}
catch (let error) {
return nil
}
return context?.objectForKeyedSubscript("server")
}
// Dummy
func getStateFromDB() -> [String: Any] {
return [ "value": 4 ]
}
drop.get("/") { req in
let state = getStateFromDB()
if let result = loadJS()?.forProperty("render")?
.call(withArguments: [state]).toDictionary() {
return try drop.view.make("index", [
"html": Node.string(result["html"] as! String),
"state": Node.string(result["state"] as! String)
])
}
throw Abort.badRequest
}
The root handler now has to render a dynamic version of index.html
with the pre-rendered html
and current state
embedded, so that
Redux can continue on the client where the server left off:
<!doctype html>
<html>
<head>
<title>React+Swift Test App</title>
</head>
<body>
<div id="root">#(html)</div>
<script>
window.__PRELOADED_STATE__ = #(state);
</script>
<script src="/js/app.js"></script>
</body>
</html>
Finally, in client.js
, we use the preloaded state from
__PRELOADED_STATE__
to hydrate our Redux store.
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import Counter from './Counter';
import { rootReducer } from './reducers';
function load(state) {
const store = createStore(rootReducer, state);
render(
<Provider store={store}>
<Counter/>
</Provider>,
document.getElementById('root')
);
}
load(window.__PRELOADED_STATE__);
And that’s it! Whenever a client accesses our app, the server will pre-render the HTML using data from the database, and send it to the client. On the client-side, React will detect that the contents has already been rendered, and not do any extra initial work.
JavaScript Development
In the server code above, loadJS()
reloads the JavaScript on
every access. This means we can run webpack --watch
on the
JavaScript code while developing, and a page reload will run
the newest version of server.js
every time. 2
For development, webpack also has a very
convenient dev server mode, which compiles
and serves all code from memory, and reloads your page on
every change. On top of this, it can even hot-load
your CSS and React modules on the fly, without any
page reload at all. Since webpack’s dev-server mode works entirely
from memory and doesn’t write anything to disk, we can’t
rely on the server to have the latest version of server.js
, and so
the pre-rendered code can be inconsistent with the client code.
To make sure this development model work as well, we can tell webpack’s
proxy to always add an X-DevServer
header when forwarding requests
to the backend; in the backend, we can then skip the pre-rendering
by sending an empty html
and state
, and let the
client do all the rendering itself again:
drop.get("/") { req in
if req.headers["X-DevServer"] != nil {
// When accessing through the dev server,
// don't pre-render anything
return try drop.view.make("index", [
"html": "",
"state": "undefined"
])
}
else {
// Prerender
...
}
On the client, we now just have to check whether the server gave us pre-rendered state, and otherwise fetch the state via AJAX:
// Check if we can preload the state from
// a server-rendered page
if (window.__PRELOADED_STATE__) {
load(window.__PRELOADED_STATE__);
}
else {
// We didn't pre-render on the server,
// so we need to get our state
fetch('/api/state')
.then(
response => response.json().then(load),
err => console.error(err)
);
}
JavaScript Debugging
A downside with rendering JavaScript on the server is that, when your
JavaScript code goes bad, it can be tricky to track down where. Doing all your
development on the client definitely helps here, but sometimes you can still
hit a bug only when rendering on the server (e.g. when you’re passing
unexpected state to render
in production). Since the render
function is a
pure function that translates a state object to HTML, you can log the state on
the server as a JSON string when an exception occurs, and run the JSON string
offline through a simple command-line Node.js script renderComponent
to get
decent stacktraces or do some debugging:
// Example:
// ./renderComponent '{"value": 42}'
var server = require('./JS/server');
if (process.argv.length < 3) {
throw Error("Missing arguments");
}
console.log(
server.render(JSON.parse(process.argv[2])).html);
Conclusion
You don’t need a Node.js backend to be able to build a universal React app. In this post, I used Swift, Vapor, and JavaScriptCore to build such an example app (for which you can find the complete sources in my Git repository). However, you can follow the same pattern on other languages, including Java (using the Nashorn JS engine) or any language with native bindings, using e.g. V8 (or even duktape!).
Unfortunately, JavaScriptCore isn’t available for Linux. As a replacement, I used Duktape with a Duktape binding for Swift on this platform ↩︎
In my experiment, JavaScriptCore’s interpreter was fast enough to not have to cache anything. With larger apps in production, you’ll probably want to either only load the JavaScript code once, or check a timestamp to see whether the file needs to be reloaded. ↩︎