Recap
In the Part 1 of this series we started building a minimal RSC framework with webpack
, babel
and express
. We learned that RSCs provide a powerful serialization/deserialization mechanism across client/server boundaries and integrated react-server-dom-webpack
to enable RSC rendering within our webpack based setup.
One problem which our movie listing app had is that if the number of movies is huge then we will have hard time finding our favorite movie. We will solve this by introducing search functionality and along the way explore and integrate React Client Components to our framework
React Client Components
In a traditional React app, we use state to store the list of movies and filtered the list based on the search query to achieve search functionality. However, with RSCs, this is not possible because the client gets only the rendered output of movie list in the form of RSC Payload there is no state involved. So, how do we update the list based on the search query?
This requires shifting our thinking from a client-first approach to a server-first approach. To understand this better, let's briefly revisit React's reconciliation process.
Reconciliation is the process by which React updates the UI when the application's state change.
- When a state update occurs, React generates a new React tree that represents the UI after the update.
- React then compares this new tree with the existing Fiber tree.
- React determines the diff between the new tree and the Fiber tree and updates the parts that have changed.
- Once reconciliation is complete, these changes are applied to the actual DOM.
To make the search functionality work, we need to update the React tree with only the movies that match the search query. If we can obtain an updated React tree that contains only the filtered movies, the search functionality is effectively handled.
If we reverse-engineer the process, we see that:
- The source of the React element tree is the RSC payload.
- The source of the RSC payload is the server.
Since RSCs are stateless, we cannot rely on traditional client-side state management to update the UI. Instead, we need to refetch the RSC payload from the server based on the search query. When the new RSC payload arrives, we can use it to recreate the React tree and trigger the reconciliation process.
Let's update the code to implement this solution.
import React from 'react';
import ReactDOMClient from 'react-dom/client';
import ReactServerDOMWebpackClient from 'react-server-dom-webpack/client';
const initialReactTreePromise = fetch('/rsc').then((response) => {
return ReactServerDOMWebpackClient.createFromReadableStream(response.body);
});
function App() {
return React.use(initialReactTreePromise);
const [tree, setTree] = React.useState(initialReactTreePromise);
React.useEffect(() => {
window.__updateTree = (stream) => {
const reactTreePromise = ReactServerDOMWebpackClient.createFromReadableStream(stream);
setTree(reactTreePromise);
};
return () => {
window.__updateTree = undefined;
};
}, []);
return React.use(tree);
}
const root = ReactDOMClient.createRoot(document.getElementById('root'));
root.render(<App />);
In the client entry point, the App
component is modified to hold the React tree in state so that we can trigger reconciliation when we update this state with a new React tree. In the effect, the __updateTree
function is exposed on the global window
object to make it easy to access in the application code. There is a better way to handle this instead of exposing the updater function on the window
object, which we will cover in next part.
In the Search
component, whenever the query is updated, we make a fetch request to /rsc
endpoint with the query as the search parameter. The response body is passed to __updateTree
function exposed on the global window
object, which constructs the new React tree from the RSC payload using createFromReadableStream
API and triggers a state update. As the result of reconciliation, the updated UI which matches the search query is rendered.
On the server, the search parameters are passed as props to the App
component. The App
component is updated to render the Search
component, and the query received as a prop is forwarded to the getMovies
function. The getMovies
function is already configured to apply filtering based on the query.
Now, if we start the server and visit the browser oh, wait, the server crashes. we've hit an error:
Error: Event handlers cannot be passed to Client Component props. <form onSubmit={function handleSearch} children=...> ^^^^^^^^^^^^^^^^^^^^^^^ If you need interactivity, consider converting part of this to a Client Component.
This error clearly tells us that we cannot pass event handlers from Server Component (Search
) to Client Component(form
). In this case, the form
is trying to receive an onSubmit
handler inside a Server Component, which isn't allowed.
Conceptually, events like submit
happen on the client (the user's browser), and they must be handled on the client side. Server Components are rendered on the server and streamed to the client as static payloads (known as the RSC payload). This payload contains only serializable data but functions like event handlers cannot be serialized. It wouldn't make sense to try and send a live function through a server response. That's why the RSC renderer throws this error.
The solution is to simply avoid serializing components that use client-side APIs like state, effects, event handlers, or anything that is not serializable by the RSC renderer. We do this by ensuring these components are not rendered in the react-server
environment but instead in a non-react-server
environment. In RSC world, these kind of components are called Client Components.
So, what is this non-react-server
environment? If you look carefully, we have been using a non-react-server
environments all along: the browser. Let's explore how to implement this approach next.
References
To avoid serializing Client components, we should not import them into the react-server
environment. Instead, we should replace them with placeholders that provide sufficient information to import Client components in the browser directly. To achieve this, React introduces a new primitive called Reference.
There are two types of references that the RSC renderer understands: Client Reference and Server Reference. Since we need to store metadata for something imported in a non-react-server
environment, we should use Client Reference. We will discuss Server Reference future posts.
To prevent client components from being imported into the react-server
environment and instead replace them with the relevant reference, we rely on bundlers. Each bundler has its own heuristics for bundling, code splitting, etc. Hence, some parts of the RSC implementation are always bundler-specific. For this reason, we have multiple react-server-dom-<bundler-name>
implementations.
Client Reference
A Client Reference is an object with the following structure:
const clientReference = {
'$$typeof': Symbol.for('react.client.reference'),
'$$id': 'a-identifier-for-the-client-component'
};
The "$$typeof"
property is well known in the React ecosystem. It is essentially a symbol
used to differentiate various React primitives such as React elements, Context, Suspense, etc. The "$$id"
property is particularly interesting—it is used to retrieve essential metadata from a manifest file for importing the client component in a non-react-server
environment.
Since we are importing Client components directly in the browser environment, these components need to be bundled separately. However, the problem is that the bundler isn't smart enough to automatically determine the starting point for bundling into the client environment within the module graph. This is where directives come into play.
A directive in JavaScript is a string that appears at the start of a module or function body. A built-in example is "use strict"
, which enables strict mode and minimizes some of JavaScript's quirks. React introduces two new directives: "use client"
and "use server"
, corresponding to Client Reference and Server Reference, respectively. These directives are not part of the JavaScript language itself but are specific to RSC bundlers.
By marking a module with the "use client"
directive, we indicate the starting point for client bundling in the module graph. When the bundler encounters this directive, it bundles the module and all its dependencies into a separate client bundle that can be directly imported in the browser. We also need to track metadata like the chunks
emitted for that module in a manifest file, which will be used by the RSC renderer when rendering a client reference. The actual structure of the manifest looks something like this:
{
"a-identifier-for-the-client-component": {
"id": "module-path",
"chunks": ["chunk1", "chunk2"],
"name": "*"
}
}
Since Webpack does not provide first-class support for analyzing these directives, the required behavior is achieved through a Webpack plugin. Writing a Webpack plugin from scratch introduces additional complexities, but fortunately, we don't have to. react-server-dom-webpack
provides a basic Webpack plugin that handles this for simple use cases.
With bundling sorted, we still need to replace client component imports with Client References. This part is tricky because we are not bundling for the server environment but instead transpiling JSX on the fly using Babel register. Therefore, we need something similar to Babel register that can replace client component imports with Client References at runtime. Thankfully, react-server-dom-webpack
already provides a solution for this as well.
I know this is a lot of theory and might be hard to grasp right away, but it will all make sense once we see it in code.
const path = require('path');
const webpack = require('webpack');
const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin');
const builder = webpack({
mode: process.env.NODE_ENV ?? 'development',
entry: path.resolve(__dirname, './client.js'),
output: {
path: path.resolve(__dirname, './dist'),
filename: 'client.js',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-react',
{
runtime: 'automatic',
},
],
],
},
},
},
],
},
plugins: [new ReactServerWebpackPlugin({ isServer: false })],
});
builder.run((err) => {
if (err) console.error('Webpack Build Failed', err);
});
The build script is modified to include the webpack plugin provided by react-server-dom-webpack/plugin
. This plugin scans the entire project for the "use client"
directive and bundles the detected module along with all its dependencies for the browser. It also generates two manifest files:
- react-client-manifest.json: Provides metadata for the RSC renderer when serializing client references.
- react-ssr-manifest.json: Used in the SSR environment to import client components. (This will be useful when we implement SSR in our framework)
The search
component is moved into search.js module marked with the "use client"
directive, indicating the starting point for client bundling. The search.js module and all its imports are bundled for the browser using the webpack plugin.
In server.js, we invoke the Node register function provided by react-server-dom-webpack/node-register
. This ensures that client component modules are replaced with the appropriate Client Reference. Remember the "$$id"
property in the Client Reference? It corresponds to a key present in the client manifest. In our case, the "$$id"
value is chosen as the module's URL, which is also used as the key in the client manifest. We then import the generated react-client-manifest.json and pass it as the second argument to the renderToPipeableStream
API.
The RSC renderer is designed in such a way that as part of RSC payload, the first set of data it sends to client is the serialized Client References of all the Client Components even though the Client components are nested deeply inside the React tree. This way the RSC runtime on the client can download all the client components as soon as possible to avoid waterfalls. Things like this is what makes RSC special for me
Finally, we have search functionality i.e., interactivity with client components implemented in our framework.
Conclusion
In this post we added support for Client Components in our framework. We explored use client
directive and how bundlers play an important role in supporting Client Components.
We now have a way to fetch data using async server components and also interactivity using client components. But the way we have implemented these are somewhat tightly coupled with our application code (movie list app) and also messy. We have exposed a state updater function __updateTree
on the global window object which might seem like a red flag.
One more small detail is that whenever we re-render the app with new React Tree, the Suspense fallbacks are triggered. For example, when we search for a movie name, the already available Movie List is replaced with Loading... text (Suspense fallback) until the new data is fetched from the server. You might be okay with this behavior, but this will cause bad UX with unwanted layout shifts, flickers etc. Instead we should follow Stale-While-Revalidate approach i.e., show the old data until the new data is fetched with some kind of indication that the data is being revalidated.
We will be addressing all these issues in the next part by building a minimal suspense enabled full stack router. Stay tuned and happy coding!!!