Deno + React 18 Alpha with Suspense

In this tutorial, we are going to build Deno + React 18 app demo.

🎓 What can I accomplish after doing this Tutorial?
* You will understand how SSR and Suspense works in React 18
* You will be able setup your own React/Deno App using SSR with Suspense
💡 What should I know?
* Web Technologies: HTML, CSS, JS
* React
* Deno (preferred)

Setup

Or install Deno cli locally, and create folder called deno-react-demo

Deno - A secure runtime for JavaScript and TypeScript
Deno is a simple, modern and secure runtime for JavaScript and TypeScript that uses V8 and is built in Rust.

Getting Started

We are going to build a deno/react environment from scratch so we can understand how the new technologies will work. In production, we would most likely choose a meta framework like NextJS or AlephJS, etc.
Skip ahead by forking this repo - https://github.com/twilson63/deno-react-demo

The first thing we will do is create a folder called scripts and create a file called dev.sh in the scripts folder. This file will be our script to run our dev server environment.

scripts/dev.sh

#!/usr/bin/env bash
deno run --unstable -A --import-map=import_map.json --no-check server.js

Run a command to turn our script into an executable script

chmod +x ./scripts/dev.sh

Lets create some files:

touch import_map.json
touch server.js render.jsx bundle.js
mkdir components
touch components/client.jsx
touch components/App.jsx
touch components/data.jsx
mkdir public
touch public/style.css
touch README.md
import_map.json is a json file that contains key/value associations to module urls, so that we can reference these external dependencies as human readable strings.

import-map.json

{
  "imports": {
    "react": "https://esm.sh/react@18.0.0-alpha-7ec4c5597?dev",
    "react-dom": "https://esm.sh/react-dom@18.0.0-alpha-7ec4c5597?dev",
    "react-dom/server": "https://esm.sh/react-dom@18.0.0-alpha-7ec4c5597/server?dev"
  }
}

components/client.jsx

import React from 'react'
import { hydrateRoot } from 'react-dom'
import { App } from './App.jsx'

hydrateRoot(document, <App assets={window.assetManifest} />)

components/App.jsx

import React from 'react'

export function App() {
  return (
  	<h1>Hello World</h1>
  )
}

server.js

import { opine, serveStatic } from 'https://deno.land/x/opine@1.5.3/mod.ts'
import render from './render.jsx'
import bundle from './bundle.js'

// bundle client
const clientJS = await bundle('./components/client.jsx')

const app = opine()

app.use(serveStatic('public'))

app.get('/scripts/client.js', (req, res) => {
  const js = clientJS.files['deno:///bundle.js']
  res.type('application/javascript').send(js)
})

app.get('/', (req, res) => {
  render(req.url, res)
})

app.listen({port: 3000})

render.jsx

import React from 'react'
import server from 'react-dom/server'
import { App } from './components/App.jsx'
import { DataProvider } from './components/data.jsx'

let assets = {
  'client.js': '/scripts/client.js'
}

export default function render(url, res) {
  
  const data = createServerData()
  
  const html = server.renderToString(
    <DataProvider data={data}>
      <App assets={assets} />
    </DataProvider>
  )
  res.send(html)
}

function createServerData() {
  let done = false;
  let promise = null;
  return {
    read() {
      if (done) {
        return;
      }
      if (promise) {
        throw promise;
      }
      promise = new Promise(resolve => {
        setTimeout(() => {
          done = true;
          promise = null;
          resolve();
        }, 2000);
      });
      throw promise;
    },
  };
}
 

bundle.js

export default function (bundle) {
  return Deno.emit(
    bundle,
    {
      bundle: "module",
      compilerOptions: {
        lib: ["dom", "dom.iterable", "esnext"],
      },
      importMap: {
        "imports": {
          "react": "https://esm.sh/react@alpha?dev",
          "react-dom": "https://esm.sh/react-dom@alpha?dev",
          "react-dom/server": "https://esm.sh/react-dom@alpha/server?dev"
        }
      },
      importMapPath: 'file:///import-map.json'
    }
  )
}

React 18 Suspense

Introduction

React 18 alpha was released and announced in June 2021, the purpose of this major release is to rollout Suspense, a feature that has been in the works for several years and has had several iterations on its approach. React 18's goal is to formalize a final stable suspense feature.

In React 18, the core team has refactored SSR (Server-side rendering) to improve the Suspense implementation. Now, with the new SSR foundations, you can dynamically hydrate different components of your application, while in the process of streaming your html, from your server.

For more details about React 18 suspense, this is a great post to read in the React 18 discussion channel.

New Suspense SSR Architecture in React 18 · Discussion #37 · reactwg/react-18
Overview React 18 will include architectural improvements to React server-side rendering (SSR) performance. These improvements are substantial and are the culmination of several years of work. Most...

Example

With Suspense we can let react render a fallback component while the data bound component is loading data, then when the data bound component is ready, react will swap the fallback out with the data component.

function Content() {
  return (
    <>
      <Suspense fallback={<div>loading...</div>}>
        <LazyComponent />
      </Suspense>
    </>
  )
}

Demo

Deno Bundle

Introduction

Deno has a built in bundler that will take a bunch of javascript files and bundle them into one file. We are using the Deno bundler to bundle our client-side javascript and serve it via the server. We can also do code-splitting and use Deno bundler to compile our split code as well.

Example

bundle.js

export default function (bundle) {
  return Deno.emit(
    bundle,
    {
      bundle: "module",
      compilerOptions: {
        lib: ["dom", "dom.iterable", "esnext"],
      },
      importMap: {
        "imports": {
          "react": "https://esm.sh/react@alpha?dev",
          "react-dom": "https://esm.sh/react-dom@alpha?dev",
          "react-dom/server": "https://esm.sh/react-dom@alpha/server?dev"
        }
      },
      importMapPath: 'file:///import-map.json'
    }
  )
}

server.js

import bundle from './bundle.js'

// bundle client
const clientJS = await bundle('./components/client.jsx')

...

app.get('/scripts/client.js', (req, res) => {
  const js = clientJS.files['deno:///bundle.js']
  res.type('application/javascript').send(js)
})

SSR

To take full advantage of Suspense, you want streaming html

Create an HTML Component

components/Html.jsx

import React from "react";

export default function Html({ assets, children, title }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>{title}</title>
      </head>
      <body>
        <noscript
          dangerouslySetInnerHTML={{
            __html: `<b>Enable JavaScript to run this app.</b>`,
          }}
        />
        {children}
        <script
          dangerouslySetInnerHTML={{
            __html: `assetManifest = ${JSON.stringify(assets)};`,
          }}
        />
        <script async src={assets['client.js']} />
      </body>
    </html>
  );
}

Now lets use the Html component to wrap our content in our App Component

components/App.jsx

import React from "react";
import Html from './Html.jsx'

export function App({ assets }) {
  return (
    <Html assets={assets} title="React 18">
      <Content />
    </Html>
  )
}

function Content() {
  return (
    <>
      <h1>Hello World</h1>
    </>
  )
}

Lets add some Lazy Component and Suspense to our App.jsx

...

const { Suspense, lazy } from React

const LazyComponent = lazy(() => import('./LazyComponent.jsx'))

...

function Content() {
  return (
    <>
      <Suspense fallback={<div>loading...</div>}>
        <LazyComponent />
      </Suspense>
      <h1>Hello World</h1>
    </>
  )
}

Create a LazyComponent.jsx

import React from 'react'

export default function () {
  return (
    <img src="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQjOtzqqREcLSOZ4Aaef5hNzwRIRcjIiYqkaA&usqp=CAU" />
  )
}

We need to bundle that component separate in our server

const lazyComponent = bundle('./components/LazyComponent.jsx')

...

app.get("/scripts/LazyComponent.jsx", (req, res) => {
  const js = lazyComponent.files['deno:///bundle.js'];
  res.type('application/javascript').send(js)
})

Streaming HTML

const { startWriting, abort } = server.pipeToNodeWritable(
    <DataProvider data={data}>
      <App assets={assets} />
    </DataProvider>,
    res,
    {
      onReadyToStream() {
        res.statusCode = 200
        res.type('text/html')
        res.write('<!doctype html>')
        startWriting()
      },
      onError(x) {
        didError = true,
          console.error(x)
      }
    }
  )

Unfortunately, I was unable to get the streaming HTML to work in Deno, if you want to see a demo of the streaming html check out this sandbox from the ReactJS Team:

Summary

This short workshop walks through a manual setup of React with SSR on Deno, it is not meant to be production ready, but a demonstration, that shows the moving parts of a React 18 application. Hopefully, it was informative and useful.

Resources

Thank you