iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔼

Getting Started with Next.js (1) — Exploring Hydration

に公開

Purpose

I've written various articles, but it all stemmed from the frustration of a jQuery old-timer not understanding what was happening when a Next.js app, which I vaguely had a generative AI write, somehow worked.

After touching on the concept of hydration a bit in Exploring Microblogs with Cloudflare Workers + Hono (2), I feel like I've finally reached a point where I can write a little about Next.js. This time, I want to briefly touch on hydration specifically. However, this is observation-based and may contain misunderstandings. I also want to briefly mention TanStack Start, which I looked at for comparison, as a bonus.

SSR + Hydration + CSR

I'll observe the operation with the same unoriginal button application every time.

$ bun create next-app next-counter-app
 Would you like to use the recommended Next.js defaults? Yes, use recommended defaults
...
Success! Created button-app at /home/xxx/work/button-app
$ cd button-app/
$ du -sh
577M    .

Then, rewrite app/page.tsx as follows:

app/page.tsx
"use client"

import { useState } from "react";

export default function Home() {
  const [count, setCount] = useState(1)
  return (
    <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
      <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
        <button
          id="counter-btn"
          className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 active:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-400 transition disabled:opacity-40"
          onClick={() => setCount(c => c + 1)}
        >
          Count: {count}
        </button>
      </main>
    </div>
  );
}

Running bun run dev results in the following screen:

Clicking the button increases the count, but as mentioned in Exploring Microblogs with Cloudflare Workers + Hono (2), the event handling is not connected by merely the content rendered via SSR on the server side. In fact, an HTML page is returned with the initial count value already expanded and no onclick attribute.

<!DOCTYPE html>
<html lang="en">
  <head>
  ...
  </head>
  <body class="geist_a71539c9-module__T19VSG__variable ...">
    <div hidden=""><!--$--><!--/$--></div>
    <div class="flex min-h-screen ...">
      <main class="flex min-h-screen ...">
        <button id="counter-btn" class="px-6 py-3 ...">
          Count: <!-- -->1
        </button>
      </main>
    </div>
    <script id="_R_">self.__next_r="Qt9witDq20cqYFMAMMIYy"</script>
    <script src="/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_956a0d3a._.js" async=""></script>
    <script>...</script>
    ...
  </body>
</html>

Now, to understand how the button click functions, I inserted logs into the framework. Based on the log output, it looked like this[1]:

The following is a simplified conceptual diagram to understand the behavior.

What is Hydration, again?

It is as follows:

According to hydrateRoot:

hydrateRoot lets you display React components inside a browser DOM node whose HTML content was previously generated by react-dom/server.

Also, Text content does not match server-rendered HTML states the following:

While rendering your application, there was a difference between the React tree that was pre-rendered from the server and the React tree that was rendered during the first render in the browser (hydration).

Hydration is when React converts the pre-rendered HTML from the server into a fully interactive application by attaching event handlers.

Roughly speaking, it seems to be the process of bridging the gap (making it interactive) between a page rendered on the server and one rendered on the client.

Where is Hydration done?

I checked where hydration is executed and found it in node_modules/next/dist/client/app-index.js. In the source code, it's the following part from next/src/client/app-index.tsx#L356-L361. If you comment this out, the button will remain "Count: 1" no matter how many times you click it.

app-index.tsx#L356-L361
    React.startTransition(() => {
      ReactDOMClient.hydrateRoot(appElement, reactEl, {
        ...reactRootOptions,
        formState: initialFormStateData,
      })
    })

What's inside hydrateRoot?

The implementation is in react-dom/src/client/ReactDOMRoot.js#L274-L358. It's too complex to follow, so I'll omit the details.

Bonus: In the case of TanStack Start

There is a framework called TanStack Start.

Full-stack Framework powered by TanStack Router for React and Solid
Full-document SSR, Streaming, Server Functions, bundling and more, powered by TanStack Router and Vite - Ready to deploy to your favorite hosting provider.

In the case of Next.js, you need to include the 'use client' directive to do the above, but with TanStack Start, you don't need to write anything; it seems to be loaded on both the server and client as needed. Execution Model might be a good reference.

Core Principle: Isomorphic by Default

All code in TanStack Start is isomorphic by default - it runs and is included in both server and client bundles unless explicitly constrained.

First, let's create an app. According to Use TanStack Start with Bun, do the following:

$ bunx @tanstack/cli create tanstack-counter-app
$ cd tanstack-counter-app
237M    .

Then, I implemented src/routes/index.tsx as follows:

src/routes/index.tsx
import { useState } from "react"
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({ component: App })

function App() {
  const [count, setCount] = useState(1)
  return (
    <div className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
      <button
        className="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg shadow-md hover:bg-blue-700 active:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-400 transition disabled:opacity-40"
        onClick={() => setCount(c => c + 1)}
      >
        Count: {count}
      </button>
    </div>
  )
}

Hydration is performed in node_modules/@tanstack/react-start/dist/plugin/default-entry/client.tsx, and the corresponding source code is react-start/src/default-entry/client.tsx#L5-L12. If hydrateRoot is commented out here as well, button clicks will stop functioning.

client.tsx#L5-L12
startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <StartClient />
    </StrictMode>,
  )
})

Summary

Although I merely inserted logs and observed the behavior, I learned that calling hydrateRoot enables the button functionality.

It seems that hydrateRoot's role is to attach event handlers and make the static HTML generated by SSR "interactive" on the client side.

By the way, while researching, it seems there was also a request for Add hydrateRoot function to hono/jsx/dom #2301 in Hono. Currently, it seems to take a different approach than Next.js and others.

脚注
  1. The "JS bundle acquisition" part was added during the article review after Gemini 3 Pro informed me. ↩︎

GitHubで編集を提案

Discussion