← All Chapters

Chapter 10

Universal JavaScript for Web Applications

Pages 357-426universal-javascriptssrwebpackreactcross-platform
Ch 1Ch 2Ch 3Ch 4Ch 5Ch 6Ch 7Ch 8Ch 9Ch 10Ch 11Ch 12Ch 13

Chapter 10: Universal JavaScript for Web Applications

Summary

Universal JavaScript (originally called Isomorphic JavaScript) is the practice of writing code that runs on both the server (Node.js) and the client (browser). This chapter covers the entire journey: from making Node.js modules work in browsers via module bundlers, to handling platform differences with cross-platform patterns, to building full server-side rendered (SSR) applications with React.

The key insight is that sharing code between server and browser requires solving three problems: module compatibility (browsers don't have require), platform differences (no fs in browsers, no document on servers), and rendering orchestration (getting server-rendered HTML to seamlessly hand off to client-side interactivity).

Key Concepts

Sharing Code with the Browser

ℹ️Info

The Fundamental Problem Node.js and browsers both run on V8, but sharing code between them is not trivial. In Node.js, we don't have the DOM or long-living views. On the browser, we don't have the filesystem and many OS interfaces. Module systems also differ -- browsers historically had no require() function. Even with native ESM support, you still need to bundle node_modules dependencies.

Another contention point is ES feature support: on the server you know exactly which Node.js version runs, but browser users may have varying levels of support. The effort is to reduce differences using abstractions, patterns, and tools.

How Module Bundlers Work

A module bundler:

  1. Starts from an entry point (e.g., src/index.js)
  2. Parses require() / import statements to build an acyclic dependency graph
  3. Recursively resolves all transitive dependencies
  4. Wraps each module in a factory function (creating a modules map)
  5. Outputs one or more bundle files the browser can load via <script>
Entry Point --> Dependency Graph --> Bundle
  index.js       |-- moduleA.js     bundle.js
                 |   +-- moduleC.js  (modules map:
                 +-- moduleB.js       factory functions
                                      + require() shim)

The bundler's runtime mimics Node.js's require() caching behavior -- each module factory executes only once, and subsequent require() calls return the cached module.exports.

Webpack

Webpack is the bundler covered in this chapter. Core concepts:

// webpack.config.js
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      { test: /\.jsx?$/, use: 'babel-loader' }
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      '__BROWSER__': true
    })
  ]
}
  • Loaders: Transform files before bundling (Babel for JSX/ES, css-loader for CSS)
  • Plugins: Modify the bundling process (DefinePlugin, TerserPlugin for minification, HtmlWebpackPlugin)
  • Dev server: Development server with hot reloading
  • Cache busting: Content hashes in filenames for cache invalidation
  • Code splitting: Break bundles into chunks loaded on demand via import()

Cross-Platform Development Fundamentals

Three strategies for handling platform differences:

1. Runtime Code Branching

if (typeof window !== 'undefined') {
  // Browser-specific code
  const data = window.localStorage.getItem('key')
} else {
  // Node.js-specific code
  const data = fs.readFileSync('/path/to/file')
}
⚠️Warning

Downside Both branches end up in the bundle. Dead code is included but never executed. If the dead branch uses require('fs'), that dependency is still bundled (webpack provides an empty shim for fs in browser mode). Fine for small checks, wasteful for large platform-specific dependencies.

2. Build-Time Code Branching

// webpack.config.js
plugins: [
  new webpack.DefinePlugin({
    '__BROWSER__': JSON.stringify(true)
  })
]

// In your code:
if (__BROWSER__) {
  renderToDom()
} else {
  renderToString()
}

The bundler replaces __BROWSER__ with true, then TerserPlugin (the minifier) eliminates the dead else branch entirely -- including any require() calls within it. Result: smaller, platform-specific bundles.

3. Module Swapping

Replace entire modules based on platform:

// package.json
{
  "browser": {
    "./src/storage.js": "./src/storage-browser.js",
    "fs": false
  }
}

Or via webpack config:

resolve: {
  alias: {
    './storage': './storage-browser'
  }
}

Setting a module to false provides an empty module (no exports).

ℹ️Info

Node.js ignores 'browser' The browser field in package.json is only read by bundlers, not by Node.js itself. Node.js always uses the main field for resolution.

React Fundamentals

createElement and JSX

// The raw API
React.createElement('h1', { className: 'title' }, 'Hello World')

// JSX (syntactic sugar, transpiled to the above)
<h1 className="title">Hello World</h1>

Components are functions that return elements:

function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>
}

Stateful Components

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

Building a Universal JavaScript App

The book walks through a progressive evolution from SPA to full SSR:

Step 1: Frontend-Only SPA

Server sends a minimal HTML shell, all rendering happens in the browser:

<div id="root"></div>
<script src="/bundle.js"></script>
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')).render(<App />)

Problem: Empty HTML until JS loads. Bad for SEO, slow first paint.

Step 2: Server-Side Rendering

// Server
import { renderToString } from 'react-dom/server'

app.get('*', (req, res) => {
  const html = renderToString(<App />)
  res.send(`
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `)
})
// Client (hydration)
import { hydrateRoot } from 'react-dom/client'
hydrateRoot(document.getElementById('root'), <App />)
ℹ️Info

renderToString vs hydrateRoot renderToString() produces static HTML on the server. hydrateRoot() on the client attaches event listeners to the existing DOM without re-creating it. The server and client must produce the same HTML, or React warns about hydration mismatches.

Step 3: Async Data Challenge

renderToString() is synchronous, but data fetching is async:

// This won't work -- renderToString can't wait for useEffect/fetch
const html = renderToString(<App />) // renders with empty state

Step 4: Universal Data Retrieval

Fetch data before rendering, pass it as props, and serialize it for the client:

// Server
const data = await fetchDataForRoute(req.url)
const html = renderToString(<App initialData={data} />)

res.send(`
  <html>
    <body>
      <div id="root">${html}</div>
      <script>
        window.__INITIAL_STATE__ = ${JSON.stringify(data)}
      </script>
      <script src="/bundle.js"></script>
    </body>
  </html>
`)
// Client
const initialData = window.__INITIAL_STATE__
hydrateRoot(
  document.getElementById('root'),
  <App initialData={initialData} />
)
⚠️Warning

Serialization Pitfall When embedding data in <script> tags, you must sanitize the JSON to prevent XSS (escape </script> strings within the data). Libraries like serialize-javascript handle this safely.

Step 5: Two-Pass Rendering

For complex apps with nested data dependencies:

Pass 1: Render tree --> discover data requirements (no HTML used)
         |
      Fetch all required data
         |
Pass 2: Render tree with data --> produce final HTML

This allows deeply nested components to declare their own data needs without the server needing to know the component tree structure up front.

Step 6: Async Pages

Route-based code splitting + SSR: each page defines its data requirements:

// Each page component has a static data fetching method
Page.fetchData = async (params) => {
  return api.getData(params.id)
}

// Route handler
app.get('/page/:id', async (req, res) => {
  const data = await Page.fetchData(req.params)
  const html = renderToString(<Page data={data} />)
  // ... send response with embedded data
})

Mind Map

Connections

  • Builds on: Chapter 2 -- The Module System (require/import, module resolution)
  • Builds on: Chapter 9 -- Behavioral Design Patterns (Strategy pattern for cross-platform)
  • Related: Chapter 8 -- Structural Design Patterns (Adapter pattern for cross-platform)
  • Next: Chapter 11 -- Advanced Recipes

20 quiz · 24 cards · 2 exercises · Ch 10 of 13