Just for my own learning today I had a bit of a play around with React hydration and server side rendering (SSR).
Whilst I've used frameworks like Next.js and Remix I've never just done the "from scratch" implementation of React SSR.
Here were the steps I followed:
- Implement a basic
GET
endpoint, I'm using Elysia andbun
, the call tohtml()
essentially just calls thereact-dom/server
renderToString function and returns aResponse
app.get("/test/ssr", async () => {
const response = html({
title: "Test SSR",
meta: {
description: defaultDescription,
"theme-color": "#1c1917",
},
links: [],
scripts: [{
src: "/client.js",
defer: true,
type: "module",
}],
body: <Test />,
styles: [GLOBAL_CSS],
});
return response;
})
- At this point all things are pretty standard, a
GET
request is sent to/test/ssr
and a response is sent back to the client as HTML - Note the
/client.js
file that's sent back to the browser as a script
<head>
<script src="/client.js" type="module" defer=""></script>
</head>
- This script loads after the HTML renders, it contains a bunch of React code which I've created using a build step
- Before the build step the
client.js
is actually aclient.tsx
file which looks like this
/// <reference lib="dom" />
import React from "react";
import { createRoot, hydrateRoot } from "react-dom/client";
import { Test } from "./pages/test"
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
if (rootElement.innerHTML.trim().length) {
hydrateRoot(rootElement, <Test />);
} else {
createRoot(rootElement).render(<Test />);
}
- The
<Test />
component has some tanstack-query setup and some basic React state
import React, { useState } from "react";
import { Something } from "../components/something";
import { Nav } from "../components/nav";
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export const Test = () => {
const [count, setCount] = useState(0);
const queryClient = new QueryClient()
return (
<QueryClientProvider client={queryClient}>
<div className={`max-w-xl mx-auto p-4 text-opacity-80 font-serif`}>
<Nav />
<button onClick={() => setCount(count + 1)}>Increment</button>
<p>{count}</p>
<Something />
</div>
</QueryClientProvider>
);
}
- The
<Something />
component actually uses tanstack-query with some sleep logic to replicate an API call
import { useQuery } from "@tanstack/react-query";
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const Something = () => {
const { data, isLoading } = useQuery({
queryKey: ["something"],
queryFn: async () => {
await sleep(5000);
return "Something";
},
})
return isLoading ? <p>Loading...</p> : <p>{data}</p>
}
- I need to bundle this code up so that the browser can interpret the JSX that I'm writing, I can use the bun build CLI tool to do this, I just need to pass the parent file (
client.tsx
) to the build tooling as it'll also bundle up the children, note that I'm also usinguglifyjs
to minify the JS making the resource size as small as possible
bun build ./client.tsx --outdir ./dist && uglifyjs ./dist/client.js --compress --mangle --output ./dist/client.js
- I then have a
/client.js
endpoint in my Elysia web server
app.get("/client.js", async () => {
const path = "./dist/client.js";
const file = Bun.file(path);
const text = await file.text();
return new Response(text, {
headers: {
"Content-Type": "text/javascript",
},
});
});
When the client.js
file executes in the browser the React hydration process occurs. The server HTML is compared to what React renders on the client. If this matches up you're all good. If it doesn't match up you'll get an error.
But essentially any code that's a child of client.tsx
you can treat like traditional SPA style React. You have useState
and you can handle async things inside of useEffect
. You can use tools like tanstack-query
or zustand
for state management.