DEV Community

roggc
roggc

Posted on

React 18: Streaming SSR with Suspense and data fetching on the server (How to)

The problem

When you try to do data fetching on the server with streaming SSR with Suspense in React 18 you face a problem, and it is the hydration mismatch. Here we will explain a way to solve it (solution extracted from here).

The solution

Here is the code of the server app:

import express from "express";
import { renderToPipeableStream } from "react-dom/server";
import React from "react";
import AppServer from "../src/components/AppServer";
import path from "path";
import { DataProvider, data } from "../src/providers/data";
import { createServerData } from "../src/api/resource";
import { Writable } from "node:stream";

const app = express();
const port = 3000;
app.get("/", (req, res) => {
  const stream = new Writable({
    write(chunk, _encoding, cb) {
      res.write(chunk, cb);
    },
    final() {
      res.write(
        `<script>
        window.globalCache={comments:[${data.comments.map((c) => `'${c}'`)}]}
        </script>`
      );
      res.end("</body></html>");
    },
  });
  const { pipe } = renderToPipeableStream(
    <DataProvider data={createServerData()}>
      <AppServer />
    </DataProvider>,
    {
      bootstrapScripts: ["/main.js"],
      onShellReady() {
        res.write("<html><body>");
        pipe(stream);
      },
    }
  );
});

app.use(express.static(path.join(__dirname, "/../dist")));

app.listen(port, () => {
  console.log(`app running on port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

The key point is in here:

 const stream = new Writable({
    write(chunk, _encoding, cb) {
      res.write(chunk, cb);
    },
    final() {
      res.write(
        `<script>
        window.globalCache={comments:[${data.comments.map((c) => `'${c}'`)}]}
        </script>`
      );
      res.end("</body></html>");
    },
  });
Enter fullscreen mode Exit fullscreen mode

We are writing a script at the end of the streaming to populate the globalCache variable in the browser with data on the server.

This is where data comes from:

import React, { createContext, useContext } from "react";

export let data;

const DataContext = createContext(null);

export function DataProvider({ children, data }) {
  return <DataContext.Provider value={data}>{children}</DataContext.Provider>;
}

export function useData() {
  const ctx = useContext(DataContext);
  if (ctx) {
    data = ctx.read();
  } else {
    data = window.globalCache;
  }
  return data;
}
Enter fullscreen mode Exit fullscreen mode

On the server data is read from the context while on the browser it is read from the globalCache variable. That's how we avoid the hydration mismatch problem.

Let's see at the createServerData function:

export function createServerData() {
    let done = false;
    let promise = null;
    let value
    return {
      read: ()=> {
        if (done) {
          return value
        }
        if (promise) {
          throw promise;
        }
        promise = new Promise((resolve) => {
          setTimeout(() => {
            done = true;
            promise = null;
            value={comments:['a','b','c']}
            resolve()
          }, 6000);
        });
        throw promise;
      }
    };
  }
Enter fullscreen mode Exit fullscreen mode

It's a promise that resolves in 6000 ms.

Now let's look at where we use the useData hook, in the Comments component:

import React from "react";
import { useData } from "../providers/data";

export default function Comments() {
  const { comments } = useData();

  return (
    <ul>
      {comments && comments.map((comment, i) => <li key={i}>{comment}</li>)}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the server it will read data from the Context while in the browser it will read data from the global variable globalCache. This is because in the browser the context will be undefined, that is because in the case of the browser we are not wrapping the App component with the DataProvider:

import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./components/App";

hydrateRoot(document.getElementById("root"), <App />);
Enter fullscreen mode Exit fullscreen mode

This is how the App component looks like:

import React, { Suspense, lazy } from "react";

const Comments = lazy(() => import("./Comments"));

const App = () => (
  <>
    <Suspense fallback={<div>loading...</div>}>
      <Comments />
    </Suspense>
  </>
);

export default App;
Enter fullscreen mode Exit fullscreen mode

And here how the AppServer component, used above (in the server), looks like:

import React from "react";
import App from "./App";

const AppServer = () => (
      <div id="root"> 
        <App />
       </div> 
);

export default AppServer;
Enter fullscreen mode Exit fullscreen mode

With that we have seen all the code of this example on how to do streaming SSR with Suspense and data fetching on the server in React 18 avoiding the problem of hydration mismatch.

Discussion (1)

Collapse
jaybe78 profile image
jb

seems react streaming is really far from production ready !?