Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

RSC From Scratch. Part 1: Server Components#5

gaearon announced inDeep Dive
Discussion options

RSC From Scratch. Part 1: Server Components

In this technical deep dive, we'll implement a very simplified version ofReact Server Components (RSC) from scratch.
This deep dive will be published in several parts:

  • Part 1: Server Components (this page)
  • Part 2: Client Components(not written yet)
  • Part 3: TBD(not written yet)

Seriously, this is a deep dive!

This deep dive doesn't explain the benefits of React Server Components, how to implement an app using RSC, or how to implement a framework using them. Instead, it walks you through the process of "inventing" them on your own from scratch.

🔬This is a deep dive for people who like to learn new technologies by implementing them from scratch.
It assumes some background in web programming and some familiarity with React.

🚧This deep dive is not intended as an introduction to how touse Server Components. We are working to document Server Components on the React website. In the meantime, if your framework supports Server Components, please refer to its docs.

😳For pedagogical reasons, our implementation will be significantly less efficient than the real one used by React.
We will note future optimization opportunities in the text, but we will strongly prioritize conceptual clarity over efficiency.

Let’s jump back in time...

Suppose that you woke up one morning and found out it's 2003 again. Web development is still in its infancy. Let's say you want to create a personal blog website that shows content from text files on your server. In PHP, it could look like this:

<?php$author ="Jae Doe";$post_content = @file_get_contents("./posts/hello-world.txt");?><html>  <head>    <title>My blog</title>  </head>  <body>    <nav>      <a href="/">Home</a>      <hr>    </nav>    <article><?phpechohtmlspecialchars($post_content);?>    </article>    <footer>      <hr>      <p><i>(c)<?phpechohtmlspecialchars($author);?>,<?phpechodate("Y");?></i></p>    </footer>  </body></html>

(We're going to pretend that tags like<nav>,<article>, and<footer> existed back then to keep the HTML easy to read.)

When you openhttp://locahost:3000/hello-world in your browser, this PHP script returns an HTML page with the blog post from./posts/hello-world.txt. An equivalent Node.js script written using the today's Node.js APIs might look like this:

import{createServer}from'http';import{readFile}from'fs/promises';importescapeHtmlfrom'escape-html';createServer(async(req,res)=>{constauthor="Jae Doe";constpostContent=awaitreadFile("./posts/hello-world.txt","utf8");sendHTML(res,`<html>      <head>        <title>My blog</title>      </head>      <body>        <nav>          <a href="/">Home</a>          <hr />        </nav>        <article>${escapeHtml(postContent)}        </article>        <footer>          <hr>          <p><i>(c)${escapeHtml(author)},${newDate().getFullYear()}</i></p>        </footer>      </body>    </html>`);}).listen(8080);functionsendHTML(res,html){res.setHeader("Content-Type","text/html");res.end(html);}

Open this example in a sandbox.

Imagine that you could take a CD-ROM with a working Node.js engine back to 2003, and you could run this code on the server. If you wanted to bring a React-flavored paradigm to that world, what features would you add, and in what order?

Step 1: Let's invent JSX

The first thing that's not ideal about the code above is direct string manipulation. Notice you've had to callescapeHtml(postContent) to ensure that you don't accidentally treat content from a text file as HTML.

One way you could solve this is by splitting your logic from your "template", and then introducing a separate templating language that provides a way to inject dynamic values for text and attributes, escapes text content safely, and provides domain-specific syntax for conditions and loops. That's the approach taken by some of the most popular server-centric frameworks in 2000s.

However, your existing knowledge of React might inspire you to do this instead:

createServer(async(req,res)=>{constauthor="Jae Doe";constpostContent=awaitreadFile("./posts/hello-world.txt","utf8");sendHTML(res,<html><head><title>My blog</title></head><body><nav><ahref="/">Home</a><hr/></nav><article>{postContent}</article><footer><hr/><p><i>(c){author},{newDate().getFullYear()}</i></p></footer></body></html>);}).listen(8080);

This looks similar, but our "template" is not a string anymore. Instead of writing string interpolation code, we're putting a subset of XML into JavaScript. In other words, we've just "invented" JSX. JSX lets you keep markup close to the related rendering logic, but unlike string interpolation, it prevents mistakes like mismatching open/close HTML tags or forgetting to escape text content.

Under the hood, JSX produces a tree of objects that look like this:

// Slightly simplified{$$typeof:Symbol.for("react.element"),// Tells React it's a JSX element (e.g. <html>)type:'html',props:{children:[{$$typeof:Symbol.for("react.element"),type:'head',props:{children:{$$typeof:Symbol.for("react.element"),type:'title',props:{children:'My blog'}}}},{$$typeof:Symbol.for("react.element"),type:'body',props:{children:[{$$typeof:Symbol.for("react.element"),type:'nav',props:{children:[{$$typeof:Symbol.for("react.element"),type:'a',props:{href:'/',children:'Home'}},{$$typeof:Symbol.for("react.element"),type:'hr',props:null}]}},{$$typeof:Symbol.for("react.element"),type:'article',props:{children:postContent}},{$$typeof:Symbol.for("react.element"),type:'footer',props:{/* ...And so on... */}}]}}]}}

However, in the end what you need to send to the browser is HTML — not a JSON tree. (At least, for now!)

Let's write a function that turns your JSX to an HTML string. To do this, we'll need to specify how different types of nodes (a string, a number, an array, or a JSX node with children) should turn into pieces of HTML:

functionrenderJSXToHTML(jsx){if(typeofjsx==="string"||typeofjsx==="number"){// This is a string. Escape it and put it into HTML directly.returnescapeHtml(jsx);}elseif(jsx==null||typeofjsx==="boolean"){// This is an empty node. Don't emit anything in HTML for it.return"";}elseif(Array.isArray(jsx)){// This is an array of nodes. Render each into HTML and concatenate.returnjsx.map((child)=>renderJSXToHTML(child)).join("");}elseif(typeofjsx==="object"){// Check if this object is a React JSX element (e.g. <div />).if(jsx.$$typeof===Symbol.for("react.element")){// Turn it into an an HTML tag.lethtml="<"+jsx.type;for(constpropNameinjsx.props){if(jsx.props.hasOwnProperty(propName)&&propName!=="children"){html+=" ";html+=propName;html+="=";html+=escapeHtml(jsx.props[propName]);}}html+=">";html+=renderJSXToHTML(jsx.props.children);html+="</"+jsx.type+">";returnhtml;}elsethrownewError("Cannot render an object.");}elsethrownewError("Not implemented.");}

Open this example in a sandbox.

Give this a try and see the HTML being rendered and served!

Turning JSX into an HTML string is usually known as "Server-Side Rendering" (SSR).It is important note that RSC and SSR are two very different things (that tend to be used together). In this guide, we'restarting from SSR because it's a natural first thing you might try to do in a server environment. However, this is only the first step, and you will see significant differences later on.

Step 2: Let's invent components

After JSX, the next feature you'll probably want is components. Regardless of whether your code runs on the client or on the server, it makes sense to split the UI apart into different pieces, give them names, and pass information to them by props.

Let's break the previous example apart into two components calledBlogPostPage andFooter:

functionBlogPostPage({ postContent, author}){return(<html><head><title>My blog</title></head><body><nav><ahref="/">Home</a><hr/></nav><article>{postContent}</article><Footerauthor={author}/></body></html>);}functionFooter({ author}){return(<footer><hr/><p><i>          (c){author}{newDate().getFullYear()}</i></p></footer>);}

Then, let's replace inline JSX tree we had with<BlogPostPage postContent={postContent} author={author} />:

createServer(async(req,res)=>{constauthor="Jae Doe";constpostContent=awaitreadFile("./posts/hello-world.txt","utf8");sendHTML(res,<BlogPostPagepostContent={postContent}author={author}/>);}).listen(8080);

If you try to run this code without any changes to yourrenderJSXToHTML implementation, the resulting HTML will look broken:

<!-- This doesn't look like valid at HTML at all... --><function BlogPostPage({postContent,author}) {...}></function BlogPostPage({postContent,author}) {...}>

The problem is that ourrenderJSXToHTML function (which turns JSX into HTML) assumes thatjsx.type is always a string with the HTML tag name (such as"html","footer", or"p"):

if(jsx.$$typeof===Symbol.for("react.element")){// Existing code that handles HTML tags (like <p>).lethtml="<"+jsx.type;// ...html+="</"+jsx.type+">";returnhtml;}

But here,BlogPostPage is a function, so doing"<" + jsx.type + ">" prints its source code. You don't want to send that function's code in an HTML tag name. Instead, let'scall this function — and serialize the JSX itreturns to HTML:

if(jsx.$$typeof===Symbol.for("react.element")){if(typeofjsx.type==="string"){// Is this a tag like <div>?// Existing code that handles HTML tags (like <p>).lethtml="<"+jsx.type;// ...html+="</"+jsx.type+">";returnhtml;}elseif(typeofjsx.type==="function"){// Is it a component like <BlogPostPage>?// Call the component with its props, and turn its returned JSX into HTML.constComponent=jsx.type;constprops=jsx.props;constreturnedJsx=Component(props);returnrenderJSXToHTML(returnedJsx);}elsethrownewError("Not implemented.");}

Now, if you encounter a JSX element like<BlogPostPage author="Jae Doe" /> while generating HTML, you willcallBlogPostPage as a function, passing{ author: "Jae Doe" } to that function. That function will return some more JSX. And you already know how to deal with JSX — you pass it back torenderJSXToHTML which continues generating HTML from it.

This change alone is enough to add support for components and passing props. Check it out:

Open this example in a sandbox.

Step 3: Let's add some routing

Now that we've got basic support for components working, it would be nice to add a few more pages to the blog.

Let's say a URL like/hello-world needs to show an individual blog post page with the content from./posts/hello-world.txt, while requesting the root/ URL needs to show an a long index page with the content from every blog post. This means we'll want to add a newBlogIndexPage that shares the layout withBlogPostPage but has different content inside.

Currently, theBlogPostPage component represents the entire page, from the very<html> root. Let's extract the shared UI parts between pages (header and footer) out of theBlogPostPage into a reusableBlogLayout component:

functionBlogLayout({ children}){constauthor="Jae Doe";return(<html><head><title>My blog</title></head><body><nav><ahref="/">Home</a><hr/></nav><main>{children}</main><Footerauthor={author}/></body></html>);}

We'll change theBlogPostPage component to only include the content we want to slotinside that layout:

functionBlogPostPage({ postSlug, postContent}){return(<section><h2><ahref={"/"+postSlug}>{postSlug}</a></h2><article>{postContent}</article></section>);}

Here is how<BlogPostPage> will look when nested inside<BlogLayout>:

Screenshot of an individual blog post page

Let's also add anewBlogIndexPage component that shows every post in./posts/*.txt one after another:

functionBlogIndexPage({ postSlugs, postContents}){return(<section><h1>Welcome to my blog</h1><div>{postSlugs.map((postSlug,index)=>(<sectionkey={postSlug}><h2><ahref={"/"+postSlug}>{postSlug}</a></h2><article>{postContents[index]}</article></section>))}</div></section>);}

Then you can nest it insideBlogLayout too so that it has the same header and footer:

Screenshot of the homepage showing all blog posts

Finally, let's change the server handler to pick the page based on the URL, load the data for it, and render that page inside the layout:

createServer(async(req,res)=>{try{consturl=newURL(req.url,`http://${req.headers.host}`);// Match the URL to a page and load the data it needs.constpage=awaitmatchRoute(url);// Wrap the matched page into the shared layout.sendHTML(res,<BlogLayout>{page}</BlogLayout>);}catch(err){console.error(err);res.statusCode=err.statusCode??500;res.end();}}).listen(8080);asyncfunctionmatchRoute(url){if(url.pathname==="/"){// We're on the index route which shows every blog post one by one.// Read all the files in the posts folder, and load their contents.constpostFiles=awaitreaddir("./posts");constpostSlugs=postFiles.map((file)=>file.slice(0,file.lastIndexOf(".")));constpostContents=awaitPromise.all(postSlugs.map((postSlug)=>readFile("./posts/"+postSlug+".txt","utf8")));return<BlogIndexPagepostSlugs={postSlugs}postContents={postContents}/>;}else{// We're showing an individual blog post.// Read the corresponding file from the posts folder.constpostSlug=sanitizeFilename(url.pathname.slice(1));try{constpostContent=awaitreadFile("./posts/"+postSlug+".txt","utf8");return<BlogPostPagepostSlug={postSlug}postContent={postContent}/>;}catch(err){throwNotFound(err);}}}functionthrowNotFound(cause){constnotFound=newError("Not found.",{ cause});notFound.statusCode=404;thrownotFound;}

Now you can navigate around the blog. However, the code is getting a bit verbose and clunky. We'll solve that next.

Open this example in a sandbox.

Step 4: Let's invent async components

You might have noticed that this part of theBlogIndexPage andBlogPostPage components looks exactly the same:

Post title with content on individual post page

Post title with content on homepage (displayed twice)

It would be nice if we could somehow make this a reusable component. However, even if you extracted its rendering logic into a separatePost component, you would still have to somehow "plumb down" thecontent for each individual post:

functionPost({ slug, content}){// Someone needs to pass down the `content` prop from the file :-(return(<section><h2><ahref={"/"+slug}>{slug}</a></h2><article>{content}</article></section>)}

Currently, the logic for loadingcontent for posts is duplicated betweenhere andhere. We load it outside of the component hierarchy because thereadFile API is asynchronous — so we can't use it directly in the component tree.(Let's ignore thatfs APIs have synchronous versions—this could've been a read from a database, or a call to some async third-party library.)

Or can we?...

If you are used to client-side React, you might be used to the idea that you can't call an API likefs.readFile from a component. Even with traditional React SSR (server rendering), your existing intuition might tell you that each of your components needs toalso be able to run in the browser — and so a server-only API likefs.readFile would not work.

But if you tried to explain this to someone in 2003, they would find this limitation rather odd. You can'tfs.readFile, really?

Recall that we're approaching everything from the first principles. For now, we areonly targeting the server environment, so we don't need to limit our components to code that runs in the browser. It is also perfectly fine for a component to be asynchronous, since the server can just wait with emitting HTML for it until its data has loaded and is ready to display.

Let's remove thecontent prop, and instead makePost anasync function loads file content via anawait readFile() call:

asyncfunctionPost({ slug}){letcontent;try{content=awaitreadFile("./posts/"+slug+".txt","utf8");}catch(err){throwNotFound(err);}return(<section><h2><ahref={"/"+slug}>{slug}</a></h2><article>{content}</article></section>)}

Similarly, let's makeBlogIndexPage anasync function that takes care of enumerating posts usingawait readdir():

asyncfunctionBlogIndexPage(){constpostFiles=awaitreaddir("./posts");constpostSlugs=postFiles.map((file)=>file.slice(0,file.lastIndexOf(".")));return(<section><h1>Welcome to my blog</h1><div>{postSlugs.map((slug)=>(<Postkey={slug}slug={slug}/>))}</div></section>);}

Now thatPost andBlogIndexPage load data for themselves, we can replacematchRoute with a<Router> component:

functionRouter({ url}){letpage;if(url.pathname==="/"){page=<BlogIndexPage/>;}else{constpostSlug=sanitizeFilename(url.pathname.slice(1));page=<BlogPostPagepostSlug={postSlug}/>;}return<BlogLayout>{page}</BlogLayout>;}

Finally, the top-level server handler can delegate all the rendering to the<Router>:

createServer(async(req,res)=>{try{consturl=newURL(req.url,`http://${req.headers.host}`);awaitsendHTML(res,<Routerurl={url}/>);}catch(err){console.error(err);res.statusCode=err.statusCode??500;res.end();}}).listen(8080);

But wait, we need toactually makeasync/await work inside components first. How do we do this?

Let's find the place in ourrenderJSXToHTML implementation where we call the component function:

}elseif(typeofjsx.type==="function"){constComponent=jsx.type;constprops=jsx.props;constreturnedJsx=Component(props);// <--- This is where we're calling componentsreturnrenderJSXToHTML(returnedJsx);}elsethrownewError("Not implemented.");

Since component functions can now be asynchronous, let's add anawait in there:

// ...constreturnedJsx=awaitComponent(props);// ...

This meansrenderJSXToHTML itself would now have to be anasync function now, and calls to it will need to beawaited.

asyncfunctionrenderJSXToHTML(jsx){// ...}

With this change, any component in the tree can beasync, and the resulting HTML "waits" for them to resolve.

Notice how, in the new code, there is no special logic to "prepare" all the file contents forBlogIndexPage in a loop. OurBlogIndexPage still renders an array ofPost components—but now, eachPost knows how to read its own file.

Open this example in a sandbox.

Note that this implementation is not ideal because eachawait is "blocking". For example, we can't evenstart sending the HTML untilall of it has been generated. Ideally, we'd want tostream the server payload as it's being generated. This is more complex, and we won't do it in this part of the walkthrough — for now we'll just focus on the data flow. However, it's important to note that we can add streaming later without any changes to the components themselves. Each component only usesawait to wait for its owndata (which is unavoidable), but parent components don't need toawait their children — even when children areasync. This is why React can stream parent components' output before their children finish rendering.

Step 5: Let's preserve state on navigation

So far, our server can only render a route to an HTML string:

asyncfunctionsendHTML(res,jsx){consthtml=awaitrenderJSXToHTML(jsx);res.setHeader("Content-Type","text/html");res.end(html);}

This is great for the first load — the browser is optimized to show HTML as quickly as possible — but it's not ideal for navigations.We'd like to be able to update "just the parts that changed"in-place, preserving the client-side state both inside and around them (e.g. an input, a video, a popup, etc). This will also let mutations (e.g. adding a comment to a blog post) feel fluid.

To illustrate the problem, let'sadd an<input /> to the<nav> inside theBlogLayout component JSX:

<nav><ahref="/">Home</a><hr/><input/><hr/></nav>

Notice how the state of the input gets "blown away" every time you navigate around the blog:

1.mp4

This might be OK for a simple blog, but if you want to be able to build more interactive apps, at some point this behavior becomes a dealbreaker. You want to let the user navigate around the app without constantly losing local state.

We're going to fix this in three steps:

  1. Add some client-side JS logic to intercept navigations (so we can refetch content manually without reloading the page).
  2. Teach our server to serve JSX over the wire instead of HTML for subsequent navigations.
  3. Teach the client to apply JSX updates without destroying the DOM (hint: we'll use React for that part).

Step 5.1: Let's intercept navigations

We're gonna need some client-side logic, so we'll add a<script> tag for a new file calledclient.js. In this file, we'll override the default behavior for navigations within the site so that they call our own function callednavigate:

asyncfunctionnavigate(pathname){// TODO}window.addEventListener("click",(e)=>{// Only listen to link clicks.if(e.target.tagName!=="A"){return;}// Ignore "open in a new tab".if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey){return;}// Ignore external URLs.consthref=e.target.getAttribute("href");if(!href.startsWith("/")){return;}// Prevent the browser from reloading the page but update the URL.e.preventDefault();window.history.pushState(null,null,href);// Call our custom logic.navigate(href);},true);window.addEventListener("popstate",()=>{// When the user presses Back/Forward, call our custom logic too.navigate(window.location.pathname);});

In thenavigate function, we're going tofetch the HTML response for the next route, and update the DOM to it:

letcurrentPathname=window.location.pathname;asyncfunctionnavigate(pathname){currentPathname=pathname;// Fetch HTML for the route we're navigating to.constresponse=awaitfetch(pathname);consthtml=awaitresponse.text();if(pathname===currentPathname){// Get the part of HTML inside the <body> tag.constbodyStartIndex=html.indexOf("<body>")+"<body>".length;constbodyEndIndex=html.lastIndexOf("</body>");constbodyHTML=html.slice(bodyStartIndex,bodyEndIndex);// Replace the content on the page.document.body.innerHTML=bodyHTML;}}

Open this example in a sandbox.

This code isn't quite production-ready (for example, it doesn't changedocument.title or announce route changes), but it shows that we can successfully override the browser navigation behavior. Currently, we're fetching the HTML for the next route, so the<input> state still gets lost. In the next step, we're going to teach our server to serve JSX instead of HTML for navigations. 👀

Step 5.2: Let's send JSX over the wire

Remember our earlier peek at the object tree that JSX produces:

{$$typeof:Symbol.for("react.element"),type:'html',props:{children:[{$$typeof:Symbol.for("react.element"),type:'head',props:{// ... And so on ...

We're going to add a new mode to our server. When the request ends with?jsx, we'll send a tree like this instead of HTML. This will make it easy for the client to determine what parts have changed, and only update the DOM where necessary. This will solve our immediate problem of the<input> state getting lost on every navigation, but that's not the only reason we are doing this. In the next part (not now!) you will see how this also lets us pass new information (not just HTML) from the server to the client.

To start off, let's change our server code to call a newsendJSX function when there's a?jsx search param:

createServer(async(req,res)=>{try{consturl=newURL(req.url,`http://${req.headers.host}`);if(url.pathname==="/client.js"){// ...}elseif(url.searchParams.has("jsx")){url.searchParams.delete("jsx");// Keep the url passed to the <Router> cleanawaitsendJSX(res,<Routerurl={url}/>);}else{awaitsendHTML(res,<Routerurl={url}/>);}// ...

InsendJSX, we'll useJSON.stringify(jsx) to turn the object tree above into a JSON string that we can pass down the network:

asyncfunctionsendJSX(res,jsx){constjsxString=JSON.stringify(jsx,null,2);// Indent with two spaces.res.setHeader("Content-Type","application/json");res.end(jsxString);}

We'll keep referring to this as "sending JSX", but we're not sending the JSX syntax itself (like"<Foo />") over the wire. We're only taking the object tree produced by JSX, and turning it into a JSON-formatted string. However, the exact transport format will be changing over time (for example, the real RSC implementation uses a different format that we will explore later in this series).

Let's change the client code to see what passes through the network:

asyncfunctionnavigate(pathname){currentPathname=pathname;constresponse=awaitfetch(pathname+"?jsx");constjsonString=awaitresponse.text();if(pathname===currentPathname){alert(jsonString);}}

Give this a try. If you load the index/ page now, and then press a link, you'll see an alert with an object like this:

{"key":null,"ref":null,"props":{"url":"http://localhost:3000/hello-world"},// ...}

That's not very useful — we were hoping to get a JSX tree like<html>...</html>. What went wrong?

Initially, our JSX looks like this:

<Routerurl="http://localhost:3000/hello-world"/>// {//   $$typeof: Symbol.for('react.element'),//   type: Router,//   props: { url: "http://localhost:3000/hello-world" }},//    ...// }

It is "too early" to turn this JSX into JSON for the client because we don't know what JSX theRouter wants to render, andRouter only exists on the server. We need tocall theRouter component to find out what JSX we need to send to the client.

If we call theRouter function with{ url: "http://localhost:3000/hello-world" } } as props, we get this piece of JSX:

<BlogLayout><BlogIndexPage/></BlogLayout>

Again, it is "too early" to turn this JSX into JSON for the client because we don't know whatBlogLayout wants to render — and it only exists on the server. We have to callBlogLayout too, and find out what JSX it want to pass to the client, and so on.

(An experienced React user might object: can't we send their code to the client so that it can execute them? Hold that thought until the next part of this series! But even that would only work forBlogLayout becauseBlogIndexPage callsfs.readdir.)

At the end of this process, we end up with a JSX tree that does not reference any server-only code. For example:

<html><head>...</head><body><nav><ahref="/">Home</a><hr/></nav><main><section><h1>Welcome to my blog</h1><div>        ...</div></main><footer><hr/><p><i>          (c) Jae Doe 2003</i></p></footer></body></html>

Now,that is the kind of tree that we can pass toJSON.stringify and send to the client.

Let's write a function calledrenderJSXToClientJSX. It will take a piece of JSX as an argument, and it will attempt to "resolve" its server-only parts (by calling the corresponding components) until we're only left with JSX that the client can understand.

Structurally, this function is similar torenderJSXToHTML, but instead of HTML, it traverses and returns objects:

asyncfunctionrenderJSXToClientJSX(jsx){if(typeofjsx==="string"||typeofjsx==="number"||typeofjsx==="boolean"||jsx==null){// Don't need to do anything special with these types.returnjsx;}elseif(Array.isArray(jsx)){// Process each item in an array.returnPromise.all(jsx.map((child)=>renderJSXToClientJSX(child)));}elseif(jsx!=null&&typeofjsx==="object"){if(jsx.$$typeof===Symbol.for("react.element")){if(typeofjsx.type==="string"){// This is a component like <div />.// Go over its props to make sure they can be turned into JSON.return{          ...jsx,props:awaitrenderJSXToClientJSX(jsx.props),};}elseif(typeofjsx.type==="function"){// This is a custom React component (like <Footer />).// Call its function, and repeat the procedure for the JSX it returns.constComponent=jsx.type;constprops=jsx.props;constreturnedJsx=awaitComponent(props);returnrenderJSXToClientJSX(returnedJsx);}elsethrownewError("Not implemented.");}else{// This is an arbitrary object (for example, props, or something inside of them).// Go over every value inside, and process it too in case there's some JSX in it.returnObject.fromEntries(awaitPromise.all(Object.entries(jsx).map(async([propName,value])=>[propName,awaitrenderJSXToClientJSX(value),])));}}elsethrownewError("Not implemented");}

Next, let's editsendJSX to turn JSX like<Router /> into "client JSX" first before stringifying it:

asyncfunctionsendJSX(res,jsx){constclientJSX=awaitrenderJSXToClientJSX(jsx);constclientJSXString=JSON.stringify(clientJSX,null,2);// Indent with two spacesres.setHeader("Content-Type","application/json");res.end(clientJSXString);}

Open this example in a sandbox.

Now clicking on a link shows an alert with a tree that looks similar to HTML — which means we're ready to try diffing it!

Note: For now, our goal is to get something working, but there's a lot left to be desired in the implementation. The format itself is very verbose and repetitive, so the real RSC uses a more compact format. As with HTML generation earlier, it's bad that the entire response is beingawaited at once. Ideally, we want to be able to stream JSX in chunks as they become available, and piece them together on the client. It's also unfortunate that we're resending parts of the shared layout (like<html> and<nav>) when we know for a fact that they have not changed. While it's important to have theability to refresh the entire screen in-place, navigations within a single layout should not ideally refetch that layout by default.A production-ready RSC implementation doesn't suffer from these flaws, but we will embrace them for now to keep the code easier to digest.

Step 5.3: Let's apply JSX updates on the client

Strictly saying, we don't have to use React to diff JSX. So far, our JSX nodesonly contain built-in browser components like<nav>,<footer>. You could start with a library that doesn't have a concept of client-side components at all, and use it to diff and apply the JSX updates. However, we'll want to allow rich interactivity later on, so we will be using React from the start.

Our app is server-rendered to HTML. In order to ask React to take over managing a DOM node that it didn't create (such as a DOM node created by the browser from HTML), you need to provide React with the initial JSX corresponding to that DOM node. Imagine a contractor asking you to see the house plan before doing renovations. They prefer to know the original plan to make future changes safely. Similarly, React walks over the DOM to see which part of the JSX every DOM node corresponds to. This lets React attach event handlers to the DOM nodes, making them interactive, or update them later. They're nowhydrated, like plants coming alive with water.

Traditionally, to hydrate server-rendered markup, you would callhydrateRoot with the DOM node you want to manage with React, and the initial JSX it was created from on the server. It might look like this:

// Traditionally, you would hydrate like thishydrateRoot(document,<App/>);

The problem is we don't have a root component like<App /> on the client at all! From the client's perspective, currently our entire app is one big chunk of JSX with exactlyzero React components in it. However, all React really needs is the JSX tree that corresponds to the initial HTML. A "client JSX" tree like<html>...</html> that we havejust taught the server to produce would work:

import{hydrateRoot}from'react-dom/client';constroot=hydrateRoot(document,getInitialClientJSX());functiongetInitialClientJSX(){// TODO: return the <html>...</html> client JSX tree mathching the initial HTML}

This would be extremely fast because right now, there are no components in the client JSX tree at all. React would walk the DOM tree and JSX tree in a near-instant, and build its internal data structure that's necessary to update that tree later on.

Then, whenever the user navigates, we'd fetch the JSX for the next page and update the DOM withroot.render:

asyncfunctionnavigate(pathname){currentPathname=pathname;constclientJSX=awaitfetchClientJSX(pathname);if(pathname===currentPathname){root.render(clientJSX);}}asyncfunctionfetchClientJSX(pathname){// TODO: fetch and return the <html>...</html> client JSX tree for the next route}

This will achieve what we wanted — it will update the DOM in the same way React normally does, without destroying the state.

Now let's figure out how to implement these two functions.

Step 5.3.1: Let's fetch JSX from the server

We'll start withfetchClientJSX because it is easier to implement.

First, let's recall how our?jsx server endpoint works:

asyncfunctionsendJSX(res,jsx){constclientJSX=awaitrenderJSXToClientJSX(jsx);constclientJSXString=JSON.stringify(clientJSX);res.setHeader("Content-Type","application/json");res.end(clientJSXString);}

On the client, we're going to call this endpoint, and then feed the response toJSON.parse to turn it back into JSX:

asyncfunctionfetchClientJSX(pathname){constresponse=awaitfetch(pathname+"?jsx");constclientJSXString=awaitresponse.text();constclientJSX=JSON.parse(clientJSXString);returnclientJSX;}

If youtry this implementation, you'll see an error whenever you click a link and attempt to render the fetched JSX:

Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).

Here's why. The object we're passing toJSON.stringify looks like this:

{$$typeof:Symbol.for("react.element"),type:'html',props:{// ...

However, if you look at theJSON.parse result on the client, the$$typeof property seems to be lost in transit:

{type:'html',props:{// ...

Without$$typeof: Symbol.for("react.element"), React on the client will refuse to recognize it as a valid JSX node.

This is an intentional security mechanism. By default, React refuses to treat arbitrary JSON objects fetched from the network as JSX tags. The trick is that a Symbol value likeSymbol.for('react.element') doesn't "survive" JSON serialization, and gets stripped out byJSON.stringify. That protects your app from rendering JSX that wasn't directly created by your app's code.

However, wedid actually create these JSX nodes (on the server) anddo want to render them on the client. So we need to adjust our logic to "carry over" the$$typeof: Symbol.for("react.element") property despite it not being JSON-serializable.

Luckily, this is not too difficult to fix.JSON.stringify accepts areplacer function which lets us customize how the JSON is generated. On the server, we're going to substututeSymbol.for('react.element') with a special string like"$RE":

asyncfunctionsendJSX(res,jsx){// ...constclientJSXString=JSON.stringify(clientJSX,stringifyJSX);// Notice the second argument// ...}functionstringifyJSX(key,value){if(value===Symbol.for("react.element")){// We can't pass a symbol, so pass our magic string instead.return"$RE";// Could be arbitrary. I picked RE for React Element.}elseif(typeofvalue==="string"&&value.startsWith("$")){// To avoid clashes, prepend an extra $ to any string already starting with $.return"$"+value;}else{returnvalue;}}

On the client, we'll pass areviver function toJSON.parse to replace"$RE" back withSymbol.for('react.element'):

asyncfunctionfetchClientJSX(pathname){// ...constclientJSX=JSON.parse(clientJSXString,parseJSX);// Notice the second argument// ...}functionparseJSX(key,value){if(value==="$RE"){// This is our special marker we added on the server.// Restore the Symbol to tell React that this is valid JSX.returnSymbol.for("react.element");}elseif(typeofvalue==="string"&&value.startsWith("$$")){// This is a string starting with $. Remove the extra $ added by the server.returnvalue.slice(1);}else{returnvalue;}}

Open this example in a sandbox.

Now you can navigate between the pages again — but the updates are fetched as JSX and applied on the client!

If you type into the input and then click a link, you'll notice the<input> state is preserved on all navigations except the very first one. This is because we haven't told React what the initial JSX for the page is, and so it can't attach to the server HTML properly.

Step 5.3.2: Let's inline the initial JSX into the HTML

We still have this bit of code:

constroot=hydrateRoot(document,getInitialClientJSX());functiongetInitialClientJSX(){returnnull;// TODO}

We need to hydrate the root with the initial client JSX, but where do we get that JSX on the client?

Our page is server-rendered to HTML; however, for further navigations we need to tell React what the initial JSX for the page was. In some cases, it might be possible to partially reconstruct from the HTML, but not always—especially when we start adding interactive features in the next part of this series. We also don't want tofetch it since it would create an unnecessary waterfall.

In traditional SSR with React, you also encounter a similar problem, but for data. You need to have the data for the page so that components can hydrate and return their initial JSX. In our case, there are no components on the page so far (at least, none that run in the browser), so nothing needs to run — but there is also no code on the client that knows how to generate that initial JSX.

To solve this, we're going to assume that the string with the initial JSX is available as a global variable on the client:

constroot=hydrateRoot(document,getInitialClientJSX());functiongetInitialClientJSX(){constclientJSX=JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__,reviveJSX);returnclientJSX;}

On the server, we will modify thesendHTML function toalso render our app to client JSX, and inline it at the end of HTML:

asyncfunctionsendHTML(res,jsx){lethtml=awaitrenderJSXToHTML(jsx);// Serialize the JSX payload after the HTML to avoid blocking paint:constclientJSX=awaitrenderJSXToClientJSX(jsx);constclientJSXString=JSON.stringify(clientJSX,stringifyJSX);html+=`<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;html+=JSON.stringify(clientJSXString).replace(/</g,"\\u003c");html+=`</script>`;// ...

Finally, we need a fewsmall adjustments to how we generate HTML for text nodes so that React can hydrate them.

Open this example in a sandbox.

Now you can type into an input, and its state is no longer lost between navigations:

2.mp4

That's the goal we originally set out to accomplish! Of course, preserving the state of this particular input isn't the point—the important part is that our app can now refresh and navigate "in-place" on any page, and not worry about destroying any state.

Note: Although a real RSC implementationdoes encode the JSX in the HTML payload, there are a few important differences. A production-ready RSC setup sends JSX chunks as they're being produced instead of a single large blob at the end. When React loads, hydration can start immediately—React starts traversing the tree using the JSX chunks that are already available instead of waiting for all of them to arrive. RSC also lets you mark some components asClient components, which means theystill get SSR'd into HTML, but their codeis included in the bundle. For Client components, only JSON of their props gets serialized. In the future, React may add extra mechanisms to deduplicate content between HTML and the embedded payload.

Step 6: Let's clean things up

Now that our code actuallyworks, we're going to move the architecture a tiny bit closer to the real RSC. We're still not going to implement complex mechanisms like streaming yet, but we'll fix a few flaws and prepare for the next wave of features.

Step 6.1: Let's avoid duplicating work

Have another look athow we're producing the initial HTML:

asyncfunctionsendHTML(res,jsx){// We need to turn <Router /> into "<html>...</html>" (a string):lethtml=awaitrenderJSXToHTML(jsx);// We *also* need to turn <Router /> into <html>...</html> (an object):constclientJSX=awaitrenderJSXToClientJSX(jsx);

Supposejsx here is<Router url="https://localhost:3000" />.

First, we callrenderJSXToHTML, which will callRouter and other components recursively as it creates an HTML string. But we also need to send the initial client JSX—so callrenderJSXToClientJSX right after, whichagain calls theRouter and all other components. We're calling every component twice! Not only is this slow, it's also potentially incorrect — for example, if we were rendering aFeed component, we could get different outputs from these functions. We need to rethink how the data flows.

What if we generated the client JSX treefirst?

asyncfunctionsendHTML(res,jsx){// 1. Let's turn <Router /> into <html>...</html> (an object) first:constclientJSX=awaitrenderJSXToClientJSX(jsx);

By this point, all our components have executed. Then, let's generate HTML fromthat tree:

asyncfunctionsendHTML(res,jsx){// 1. Let's turn <Router /> into <html>...</html> (an object) first:constclientJSX=awaitrenderJSXToClientJSX(jsx);// 2. Turn that <html>...</html> into "<html>...</html>" (a string):lethtml=awaitrenderJSXToHTML(clientJSX);// ...

Now components are only called once per request, as they should be.

Open this example in a sandbox.

Step 6.2: Let's use React to render HTML

Initially, we needed a customrenderJSXToHTML implementation so that we could control how it executes our components. For example, we've need to add support forasync functions to it. But now that we pass a precomputed client JSX tree to it, there is no point to maintaining a custom implementation. Let's delete it, and use React's built-inrenderToString instead:

import{renderToString}from'react-dom/server';// ...asyncfunctionsendHTML(res,jsx){constclientJSX=awaitrenderJSXToClientJSX(jsx);lethtml=renderToString(clientJSX);// ...

Open this example in a sandbox.

Notice a parallel with the client code. Even though we've implemented new features (likeasync components), we're still able to use existing React APIs likerenderToString orhydrateRoot. It's just that the way we use them is different.

In a traditional server-rendered React app, you'd callrenderToString andhydrateRoot with your root<App /> component. But in our approach, we first evaluate the "server" JSX tree usingrenderJSXToClientJSX, and pass itsoutput to the React APIs.

In a traditional server-rendered React app, components execute in the same wayboth on the server and the client. But in our approach, components likeRouter,BlogIndexPage andFooter are effectively server-only (at least, for now).

As far asrenderToString andhydrateRoot are concerned, it's pretty much as ifRouter,BlogIndexPage andFooter have never existed in the first place. By then, they have already "melted away" from the tree, leaving behind only their output.

Step 6.3: Let's split the server in two

In the previous step, we've decoupled running components from generating HTML:

  • First,renderJSXToClientJSX runs our components to produce client JSX.
  • Then, React'srenderToString turns that client JSX into HTML.

Since these steps are independent, they don't have to be done in the same process or even on the same machine.
To demonstrate this, we're going splitserver.js into two files:

  • server/rsc.js: This server will run our components. It always outputs JSX — no HTML. If our components were accessing a database, it would make sense to run this server close to the data center so that the latency is low.
  • server/ssr.js: This server will generate HTML. It can live on the "edge", generating HTML and serving static assets.

We'll run them both in parallel in ourpackage.json:

"scripts":{"start":"concurrently \"npm run start:ssr\" \"npm run start:rsc\"","start:rsc":"nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js","start:ssr":"nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"},

In this example, they'll be on the same machine, but you could host them separately.

The RSC server is the one that renders our components. It's only capable of serving their JSX output:

// server/rsc.jscreateServer(async(req,res)=>{try{consturl=newURL(req.url,`http://${req.headers.host}`);awaitsendJSX(res,<Routerurl={url}/>);}catch(err){console.error(err);res.statusCode=err.statusCode??500;res.end();}}).listen(8081);functionRouter({ url}){// ...}// ...// ... All other components we have so far ...// ...asyncfunctionsendJSX(res,jsx){// ...}functionstringifyJSX(key,value){// ...}asyncfunctionrenderJSXToClientJSX(jsx){// ...}

The other server is the SSR server. The SSR server is the server that our users will hit. It asks the RSC server for JSX, and then either serves that JSX as a string (for navigations between pages), or turns it into HTML (for the initial load):

// server/ssr.jscreateServer(async(req,res)=>{try{consturl=newURL(req.url,`http://${req.headers.host}`);if(url.pathname==="/client.js"){// ...}// Get the serialized JSX response from the RSC serverconstresponse=awaitfetch("http://127.0.0.1:8081"+url.pathname);if(!response.ok){res.statusCode=response.status;res.end();return;}constclientJSXString=awaitresponse.text();if(url.searchParams.has("jsx")){// If the user is navigating between pages, send that serialized JSX as isres.setHeader("Content-Type","application/json");res.end(clientJSXString);}else{// If this is an initial page load, revive the tree and turn it into HTMLconstclientJSX=JSON.parse(clientJSXString,parseJSX);lethtml=renderToString(clientJSX);html+=`<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;html+=JSON.stringify(clientJSXString).replace(/</g,"\\u003c");html+=`</script>`;// ...res.setHeader("Content-Type","text/html");res.end(html);}}catch(err){// ...}}).listen(8080);

Open this example in a sandbox.

We're going to keep this separation between RSC and "the rest of the world" (SSR and user machine) throughout this series. Its importance will become clearer in the next parts when we start adding features to both of these worlds, and tying them together.

(Strictly speaking, it is technically possible to run RSC and SSR within the same process, but their module environments would have to be isolated from each other. This is an advanced topic, and is out of scope of this post.)

Recap

And we're done for today!

It might seem like we've written a lot of code, but we really haven't:

Have a read through them. To help the data flow "settle" in our minds, let's draw a few diagrams.

Here is what happens during the first page load:

A diagram showing the SSR server proxying the request to the RSC server, and then turning the output into HTML with inlined RSC payload

And here is what happens when you navigate between pages:

A diagram showing the SSR server proxying the request to the RSC server, and returning the payload as is so that React can apply the DOM update on the client

Finally, let's establish some terminology:

  • We will sayReact Server (or just capitalized Server) to meanonly the RSC server environment. Components that exist only on the RSC server (in this example, that's all our components so far) are calledServer Components.
  • We will sayReact Client (or just capitalized Client) to mean any environment that consumes the React Server output. As you've just seen,SSR is a React Client — and so is the browser. We don't support components on the Clientyet — we'll build that next! — but it shouldn't be a huge spoiler to say that we will call themClient Components.

Challenges

If reading through this post wasn't enough to satisfy your curiosity, why not play with thefinal code?

Here's a few ideas for things you can try:

  • Add a random background color to the<body> of the page, and add a transition on the background color. When you navigate between the pages, you should see the background color animating.
  • Implement support forfragments (<>) in the RSC renderer. This should only take a couple of lines of code, but you need to figure out where to place them and what they should do.
  • Once you do that, change the blog to format the blog posts as Markdown using the<Markdown> component fromreact-markdown. Yes, our existing code should be able to handle that!
  • Thereact-markdown component supports specifying custom implementations for different tags. For example, you can make your ownImage component and pass it as<Markdown components={{ img: Image }}>. Write anImage component that measures the image dimensions (you can use some npm package for that) and automatically emitswidth andheight.
  • Add a comment section to each blog post. Keep comments stored in a JSON file on the disk. You will need to use<form> to submit the comments. As an extra challenge, extend the logic inclient.js to intercept form submissions and prevent reloading the page. Instead, after the form submits, refetch the page JSX so that the comment list updates in-place.
  • Pressing the Back button currently always refetches fresh JSX. Change the logic inclient.js so that Back/Forward navigation reuses previously cached responses, but clicking a link always fetches a fresh response. This would ensure that pressing Back and Forward always feels instant, similar to how the browser treats full-page navigations.
  • When you navigate between two different blog posts, theirentire JSX gets diffed. But this doesn't always make sense — conceptually, these are twodifferent posts. For example, if you start typing a comment on one of them, but then press a link, you don't want that comment to be preserved just because the input is in the same location. Can you think of a way to solve this? (Hint: You might want to teach theRouter component to treat different pages with different URLs as different components by wrapping the{page} with something. Then you'd need to ensure this "something" doesn't get lost over the wire.)
  • The format to which we serialize JSX is currently very repetitive. Do you have any ideas on how to make it more compact? You can check a production-ready RSC framework like Next.js App Router, or ourofficial non-framework RSC demo for inspiration. Even without implementing streaming, it would be nice to at least represent the JSX elements in a more compact way.
  • Imagine you wanted to add support for Client Components to this code. How would you do it? Where would you start?

Have fun!

You must be logged in to vote

Replies: 0 comments

Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment
Labels
None yet
1 participant
@gaearon

[8]ページ先頭

©2009-2025 Movatter.jp