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}`);
});
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>");
},
});
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;
}
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;
}
};
}
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>
);
}
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 />);
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;
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;
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.
Top comments (2)
this is exactly what I've wondered, thanks for sharing
seems react streaming is really far from production ready !?