Notes From the Cutting-Room Floor
This post is not a transcript of my React Conf talk, React for Two Computers, nor a summary of it. Rather, it consists of a few additional thoughts on React Server Components that, for one reason or another, didn't quite fit into the talk's narrative. They're the fragments that resisted tidy organization.
These are loose threads: observations about the mental model, a historical note on how we got here, and some considerations for those already using server components in production. They are meant to complement the talk, not replace it.
The Old Mental Model: A Single Computer
For a long time, our mental model of React was essentially that of a single computer. This was true even when we used server-side rendering. The server would run React to produce HTML, and the client would then take over to manage interactivity. But the mental model wasn't really about two distinct environments; it was about one continuous, stateful application that happened to be rendered initially on the server and then hydrated on the client.
This model treated the entire application as a single, cohesive unit. It was an approximation, but a useful one. We didn't have to constantly think about which code was running where, because with full hydration, nearly everything eventually ran on the client anyway.
React Server Components shatter this unitary view. The fundamental idea is to accept that our applications are, and have always been, distributed systems—composed of code that runs on two separate computers. The model isn't about a single app that's rendered twice, but about a single component tree whose nodes execute across different machines based on their needs and capabilities.
An Unintended Consequence of SSR
It is worth remembering that full hydration wasn't a deliberate end state for React. We inadvertently cornered ourselves into it. When we started doing server-side rendering, you had to attach event handlers to the HTML so it could become interactive. A natural approach was to run the entire app on the server to generate the HTML, and then run the entire app again on the client to attach those handlers. The result was that you ended up shipping all the code for the whole app to the browser, whether or not it was necessary.
What began as a natural implementation strategy became an architectural constraint. Over time, this "ship everything" approach became unsatisfying, but it was the most direct way to make server-rendered HTML interactive. It was a pragmatic step, yet it tethered us to a model where the full application code is always the client's burden, regardless of how much actually benefits from executing there.
When Data Changes on the Server
React Server Components also resolve a tension that existed previously. In the past, when a client component needed data from the server, you'd use a route such as an API endpoint. The component would send a request and handle the different states of that request—loading, success, error. This was effective, but the data fetching logic lived in a fundamentally different place from the rendering logic.
The problem is that your API and your component can fall out of sync. If the API changes its response shape, or the component changes what it expects, there's no compiler or type checker watching to guarantee the two still fit together. They represent two separate concerns that must be manually coordinated.
What is interesting about Server Components is that they shift the nature of data fetching from an interactive, client-driven process to a synchronous, server-side one. On the server, you can await a database query directly within your component's body. There's no visible loading state for that network round trip, because there is no network round trip from the component's perspective; the fetch is part of the server's execution. Access on the client is resolved to the component's output, whose props already incorporate that server-side data. This is one of the biggest shifts in day-to-day ergonomics.
This is a direct contrast to the client-run world, where you are constantly devising abstractions to manage asynchronous data. Those abstractions, like useEffect with state, or data-fetching libraries built on top of it, disappear entirely for the server portion of your code. Instead of fetching data in an effect and then storing it, you're fetching it where the component starts its life, adding a data dependency where none could previously exist.
In the model with two computers, fetching can feel more direct. The component code essentially says, "Get me this data, then render this." Because it's executing once on the server at request time, imperatively awaiting a promise in the body is the most straightforward way to handle that. It maps well to our mental model of how the server code runs and simplifies what the component itself has to manage.
Two Ways to Describe Computation
There is an intuitive difference between a tag and a function call, even when both appear to express the same thing:
<p>Hello</p>alert('Hello');Tags and function calls share three behaviors: they are referred to by name, they accept arguments, and they can be nested. Tags usually take noun names (p, button) while functions are conventionally verbs (createElement, querySelectorAll). More importantly, both support deep nesting, though people tend to nest tags more aggressively than function calls. That preference is a symptom of a deeper difference: tags are used to build declarative structures, while function calls tend to express imperative sequences.
An imperative program, or a "recipe," has a strict order to top-level steps. Nesting it heavily obscures the sequencing, so it's usually written with function calls:
const eggs = crackEggs();
heat(fryingPan);
put(fryingPan, butter);
await delay(30000);
put(fryingPan, eggs);A declarative program, or a "blueprint," describes how things are composed of other things, without prescribing when each is constructed. It naturally nests deeply, so tags fit it well:
<Building>
<Roof />
<Floor>
<Room />
<Room>
<Person name="Alice" />
<Person name="Bob" />
</Room>
</Floor>
<Basement />
</Building>A real program such as a React component mixes both styles: imperative recipes for event handlers, declarative blueprints for its JSX tree. The program as a whole must do something eventually. A blueprint alone is inert; a recipe, or an interpreter like React, must walk it to make it real. A tag, then, is a potential function call—a call described as data, which may or may not run later.
Calling Across a Network
The distinction between calling a function and describing the call crystallizes when the caller and callee live on different computers. The usual approach is to reach for HTTP and a REST API. That works, but it hides a loss: the code to execute is no longer directly referenced. It isn't typechecked, and you can't "click into" the remote endpoint online
An async/await model solves the immediate problem of pausing execution:
const name = await callNetwork('https://another-computer/fn=prompt&args=Who+are+you?');
await callNetwork('https://yet-another-computer/fn=alert&args=Hello,+' + name);
console.log('Done.');Getting type-safety back needs a different trick, an import rpc:
import rpc { prompt, alert } from './stuff';
const name = await prompt('Who are you?');
await alert('Hello, ' + name);
console.log('Done.');The remaining problem is the reverse direction. What if the far side can receive a request but never reply? You can still send work there (a prompt with no means to get its answer back), but you can't use the regular syntax for the remote call, because there is no guarantee the call completes, and there is no way to obtain a result. You need a notation for a call that may never happen, a "blueprint of a function call"
alert⧼'Hello'⧽;Nothing on the near side can use the result of such a call; any process that depends on the response must itself be moved to the far side. That constrains the mental model: dependencies between remote calls cannot be expressed as sequential lines of code; they have to be nesting. For instance, to both prompt and then alert with the prompt's answer, you must express it in that fashion:
alert⧼
concat⧼
'Hello, ',
prompt⧼'Who are you?'⧽
⧽
⧽;A tiny example like that can be sent as JSON and then decoded remotely:
{
fn: 'alert',
args: [{
fn: 'concat',
args: ['Hello, ', {
fn: 'prompt',
args: ['Who are you?']
}]
}]
}function interpret(json) {
if (json && json.fn) {
// Find a global function by its name
let fn = window[json.fn];
// Interpret any nested potential calls in the arguments
let args = json.args.map(arg => interpret(arg));
// Actually perform the call now
let result = fn(...args);
// If it returned more potential calls, do them next
return interpret(result);
} else {
return json;
}
}The notation now looks like code but behaves like data. Distant calls
syntax reveals a deeper point. Extracting an alternative design, the JavaScript community's promise of "tags" was that they allow you to use the calling site and the function implementation as separated concerns. The loose notion of a potential function call gives the early side raw material to decide where to compute.
Splitting Computation in Pieces
It can be useful to separate the steps themselves into groups that run in different environments. You can model turning one function's execution into pieces by moving one snippet into another snippet as text:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}Viewed that way, a split program is a function that sends its own closure over the network when it runs. The physical order of the parts remains clear. Data flows strictly from the first computer into the second; the second is isolated—it shares no state, globals, or module system—and apart from the closed-over snapshot, the running of old functions and new functions cannot coordinate in real time.
You can choose which side does the bulk of the work on each computation. For example, let's we want to perform FizzBuzz alerts; running n alerts from 1 to a chosen value. Any overall piece can instead be rewritten the other side; the receiving side sees only the results and performs the alert, but never initiates a network call itself.
[BLOCK_36], [BLOCK_37], plus an example of precomputing messages from part of one side:
function fizzBuzz() {
const n = Number(prompt('How many?'));
const messages = [];
for (let i = 1; i <= n; i++) {
if (i % 3 === 0 && i % 5 === 0) {
messages.push('FizzBuzz');
} else if (i % 3 === 0) {
messages.push('Fizz');
} else if (i % 5 === 0) {
messages.push('Buzz');
} else {
messages.push(i);
}
}
return `function resume() {
const messages = ${JSON.stringify(messages)};
messages.forEach(alert);
}`;
}Such a flexible split is clearly nicer to write with a reference rather than inside a string. One can import tag for a function you want to run on the second, "late," side:
import tag { resume } from './stuff';
function greeting() {
const name = prompt('Who are you?');
return resume(name);
}These split programs are “client-server,” but they are clarified by a different lens: they are one function passing the rest of itself forward.
Tags as Shared Code Between the Sides
Composability becomes visible when the two-side model is expressed with your custom tag syntax. Data can stay inside the structure:
function greeting() {
return {
fn: 'alert',
args: [{
fn: 'concat',
args: ['Hello, ', {
fn: 'prompt',
args: ['Who are you?']
}]
}]
};
}An operation like a prompt that has side effects could be left unexploded if it is confined deeper in the tree. The generic interpret routine learns to know two ways of thinking:
interpret(greeting(), {});
// {
// fn: 'alert',
// args: [{
// fn: 'concat',
// args: ['Hello, ', {
// fn: 'prompt',
// args: ['Who are you?']
// }]
// }]
// };interpret(greeting(), {
prompt: window.prompt
});If a function passes a value to another function, its resulting structure remains transparent only when the relationship is treated as a reserved syntax.
Two Kinds of Functions
Functions split into those that embed their arguments and those that introspect them. A rope-tier cannot tie pumpkins:
function concat(a, b) {
return a + b;
}
function pair(a, b) {
return [a, b];
}The rope-tier example concretely parallels a function like concat that concatenates its strings, versus pair that creates a fresh pair around its input, never examining it. If you promise you will not interpret some tags inline at call site, a piece that just embeds may be fed to functions to process.
You reinterpret all functions as needing arguments, only the embedding functions could also be evaluated with uninterpreted tag arguments. Naming such is a natural constraint. These embedding-only functions, that do not introspect, honestly should have a capital first letter, one that then insists scripts run in the required inside-out order at the end, after these process:
function App() {
return [
<Greeting />,
<P>The time is: <Clock /></P>
];
}
function Clock() {
return new Date().toString();
}
function Greeting() {
return (
<P>
Hello,
<prompt>Who are you?</prompt>
</P>
);
}
function P(...children) {
return (
<alert>
<concat>
{children}
</concat>
</alert>
);
}
function alert(message) {
window.alert(message);
}
function prompt(message) {
return window.prompt(message);
}
function concat(a, b) {
return a + b;
}const primitives = interpret(<App />, {
App,
Greeting,
Clock,
P
});
// [
// { fn: 'alert', args: [{ fn: 'concat', args: ['Hello', { fn: 'prompt', args: ['Who are you?'] }] }] },
// { fn: 'alert', args: [{ fn: 'concat', args: ['The time is: ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)'] }] }
// ]These embedding-compatible are the "Components": They don't introspect, so the time in between is suspended and can be arbitrarily deferred or split. Primitives run last - "perform" these tags in an aware recursive order across a primitive set:
function perform(json, knownTags) {
if (json && json.fn) {
let fn = knownTags[json.fn];
let args = perform(json.args, knownTags);
let result = fn(...args);
return perform(result, knownTags);
} else if (Array.isArray(json)) {
return json.map(item => perform(item, knownTags));
} else {
return json;
}
}perform(primitives, {
alert,
concat,
prompt
});
// undefinedTime loses. But not completely. A list of possible optimized "primitives" help integrate built-in environments:
- Primitives can be implemented in lower-level languages (
RustorC++) and then reused globally.
- Declarative trees are powerful enough to be written with markup-style tags.
Soon a whole suite of these primitives can be like the documented ones used already. Yet each remains strictly a recognizable lower-level piece of perform, handy place.
Two Half-Programs and the Door Between Them
So far we have looked at splitting a computation across time: Primitives must run together in one moment, while thinking Components can be evaluated separately and in any order. Now we turn to splitting a computation across space, and the key idea is that a function split across time also carries data with it. A nested function such as () => alert(name) does not just represent code; it bundles a piece of code with the variable it closes over.
If we pull that nested function out to the top level, we must make the data explicit:
function resume(name) {
alert('Hello, ' + name);
}The original function can then return another nested function that supplies the data:
function greeting() {
const name = prompt('Who are you?');
return () => resume(name);
}
function resume(name) {
alert('Hello, ' + name);
}
const resume = greeting(); // Run the first step
resume(); // Run the second stepWith that, we can make the pairing explicit by returning the code and its data together:
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
function resume(name) {
alert('Hello, ' + name);
}
const [code, data] = greeting(); // Run the first step
code(data); // Run the second stepThis looks a lot like a tag object, except the fn field holds an actual function rather than a string. So a tag can be seen not just as a potential function call, but as a pairing of code with the data that code needs.
From Time to Space
Splitting a computation across space uses the same pairing, but now the code travels as a string. We could have greeting() interpolate the name into its own source code:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}
const code = greeting();If the string is sent to another computer, that second machine sees only the code it was sent. Yet the real program includes both halves. We can make this more visible by moving the code out of the string entirely, and passing the data as an argument instead:
const RESUME_CODE = `
function resume(name) {
alert('Hello, ' + name);
}
`;
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}
const [code, data] = greeting();
const jsonString = JSON.stringify([code, data]);Now the function must return both the code and the data, so that the other computer can serialize them, transport them, and finally call code(data). The trouble is that the code inside a template literal is trapped; we cannot edit it with syntax highlighting or typechecking.
We want to write the code as a normal top‑level function, but we still need to send it. This creates two distinct worlds. In the first world, resume is only a string, a plan. In the second, resume is an actual function and knows nothing of greeting. The two worlds are part of the same program, separated by an extremely wide gap:
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}function resume(name) {
alert('Hello, ' + name);
}Squinting at the result, the true program shape is still visible:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}But a split view is fairer to both sides. Neither world takes priority — they are halves, divided by space:
function greeting() {
const name = prompt('Who are you?');
return [RESUME_CODE, name];
}function resume(name) {
alert('Hello, ' + name);
}The connection between them is simply a chosen name. If we give resume a unique global name, the other world can refer to it directly:
function greeting() {
const name = prompt('Who are you?');
return ['resume', name];
}window['resume'] = function resume(name) {
alert('Hello, ' + name);
}This clunky approach does open an explicit connection. It resembles the way browser primitives such as document.createElement('p') are globally known; you do not import them, you just call a global name. The browser's own internals behave like a second, “late” world — much of that world is not JavaScript, it runs after your call, and much of its logic resolves only when it finally executes. In that sense, a primitive tag really is a promise of future work.
But for code you author, you want more than a fragile global name.
The Import Problem
The natural instinct is to export the function and import it into the other file. That compiles, but it does not give what you need. An import brings the function object itself into the importing world — that is, it merges the two worlds together. That shape will not work:
function greeting() {
const name = prompt('Who are you?');
return function resume() {
alert('Hello, ' + name);
};
}The shape the program requires has the backticks between them:
function greeting() {
const name = prompt('Who are you?');
return `function resume() {
alert('Hello, ' + ${JSON.stringify(name)});
}`;
}The problem becomes noticeable if the late function imports a third‑party library as well:
import { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}A normal import here would splice the entire tree into the world of the component that imports it, discarding the boundary entirely. What we want is for both worlds to stay internally consistent, isolated programs. Then, to connect them, we use a single door.
A Door Between the Worlds
We can invent a small syntax import tag that lets a file refer to an export from another file without loading or executing anything on purpose. It simply yields an identifier that designates where the code of that function lives. In the simplest form, the identifier is a string of the filename plus the export name:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// '/src/stuff/resume.js#resume',
// 'Dan'
// ]Exactly what the identifier looks like can vary with the environment in which the late world lives. A Node.js process benefits from direct paths it can import() from the filesystem. In a browser, a bundler often groups such functions into chunks and assigns its own identifiers to each chunk:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'chunk123#module456#resume',
// 'Dan'
// ]In the most extreme simple case, when all late code is bundled into a single file, the identifier might just be the function name in global scope:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}
const [code, data] = greeting();
// [
// 'window.resume',
// 'Dan'
// ]Whatever its exact form, the identifier gives the early world a way to reference late code without bringing it into that world. That is the door:
function greeting() {
const name = prompt('Who are you?');
return `
import { showToast } from 'toast-library';
function resume() {
showToast('Hello, ' + name);
}
`;
}The early world needs to be written with normal source:
import tag { resume } from './resume';
function greeting() {
const name = prompt('Who are you?');
return [resume, name];
}import { showToast } from 'toast-library';
export function resume(name) {
showToast('Hello, ' + name);
}Cleaning Up the Syntax
With a door available, a small syntactic housekeeping will make writing Components comfortable. Previously, tags were objects of { fn: 'p', args: [...] }. That style cannot express named attributes, so we move instead to named properties:
function App() {
return {
type: 'div',
props: {
children: [
{ type: 'Greeting', props: {} },
{
type: 'p',
props: {
className: 'text-purple-500',
children: ['The time is: ', { type: 'Clock', props: {} }]
}
}
]
}
};
}With that, interpret adjusts how it reads tags, and perform now can apply attributes like className to concrete DOM nodes:
function interpret(json, knownTags) {
if (json && json.type) {
if (knownTags[json.type]) {
let Component = knownTags[json.type];
let props = json.props;
let result = Component(props);
return interpret(result, knownTags);
} else {
let children = json.props.children?.map(arg => interpret(arg, knownTags));
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(item => interpret(item, knownTags));
} else {
return json;
}
}function perform(json) {
if (json && json.type) {
let tagName = json.type;
let node = document.createElement(tagName);
for (let [propKey, propValue] of Object.entries(json.props)) {
if (propKey === 'children') {
let children = perform(propValue);
for (let child of [children].flat().filter(Boolean)) {
node.appendChild(child);
}
} else {
node[propKey] = propValue;
}
}
return node;
} else if (typeof json === 'string') {
return document.createTextNode(json);
} else if (Array.isArray(json)) {
return json.map(perform);
} else {
return json;
}
}A second convenience follows. Having to pass a dictionary of known Components to interpret is clumsy:
function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}
const primitives = interpret(<App />, {
App,
Greeting,
Clock
});The tag name <Greeting /> could equally well resolve Greeting directly, since that function is in scope. A new convention settles it: tags beginning with a capital letter are treated as Components and their type is the actual Component function. Lowercase names remain as strings for Primitives. Then interpret does all the work by checking the type of the object’s type:
function interpret(json) {
if (json && json.type) {
if (typeof json.type === 'function') {
let Component = json.type;
let props = json.props;
let result = Component(props);
return interpret(result);
} else {
let children = json.props.children?.map(interpret);
let props = { ...json.props, children };
return { type: json.type, props };
}
} else if (Array.isArray(json)) {
return json.map(interpret);
} else {
return json;
}
}Pieces can now dissolve cleanly in one pass:
const primitives = interpret(<App />);
// {
// type: 'div',
// props: {
// children: [{
// type: 'p',
// props: {
// children: [
// 'Hello, ',
// { type: 'input', props: { placeholder: 'Who are you?' } }
// ]
// }
// }, {
// type: 'p',
// props: {
// children: ['The time is ', 'Wed Apr 09 2025 15:13:04 GMT+0900 (Japan Standard Time)']
// }
// }]
// }
// }Calling interpret yields a Primitive tree; calling perform turns it into a DOM tree:
const tree = perform(primitives);
// [HTMLDivElement]
document.body.appendChild(tree);Splitting a Real Program
Here is a typical tree to split between two computers:
export function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}App and Greeting are meant to run early, while Clock must run late. Practically that means moving Clock into its own file and exporting it:
export function Clock() {
return new Date().toString();
}From the early side, we import tag that file, creating a reference rather than a function object:
import tag { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>The time is: <Clock /></p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}export function Clock() {
return new Date().toString();
}By our new convention, <Clock /> produces a tag whose type would ordinarily be the Clock function itself. Now, however, because this import was special, the object holds a string identifier such as '/src/Clock.js#Clock' instead.
Components that run early we call Early Components; those sent to the other machine to finish we call Late Components. Interpret dissolves the early ones, then the late ones continue.
When we invoke interpret(<App />) on the first machine, the early leaves dissolve and drop out. Only Primitives and still‑unresolved late Component references remain:
{
type: 'div',
props: {
children: [{
type: 'p',
props: {
children: [
'Hello, ',
{ type: 'input', props: { placeholder: 'Who are you?' } }
]
}
}, {
type: 'p',
props: {
children: [
'The time is ',
{
type: '/src/Clock.js#Clock',
props: {}
}
]
}
}]
}
}interpret ignores anything that is not a function, so the reference '/src/Clock.js#Clock' stays put. We can now serialize that residual tree, which contains only strings and no functions, completely safely:
const lateComponents = intepret(<App />);
const jsonString = JSON.stringify(lateComponents);On the receiving machine, conversely, calling perform immediately fails because it meets a late Component reference, not a real Primitive:
function perform(json) {
if (json && json.type) {
let tagName = json.type;
// 🔴 Failed to execute 'createElement' on 'Document':
// The tag name provided ('/src/Clock.js#Clock') is not a valid name.
let node = document.createElement(tagName);
// ...
return node;
} else {
// ...
}
}The reference must first be turned into an actual function. If the environment can provide a loadReference() helper — as might be supplied by a framework or a bundler — we can resolve the reference to real code:
await loadReference('/src/Clock.js#Clock');
// function Clock(){}After loading all references in parallel, the residual tree has only Primitives and late Component functions. Then a second interpret can dissolve Clock components and a final perform produces the DOM:
const primitives = interpret(lateComponents);
const tree = perform(primitives);
document.body.appendChild(tree);To recap, the early machine dissolves early Components, producing a portable string:
const lateComponents = intepret(<App />);
const jsonString = JSON.stringify(lateComponents);The late machine loads references from that string and dissolves late Components, going back to primitives at the end:
const pendingPromises = [];
const lateComponents = JSON.parse(jsonString, (key, value) => {
if (typeof value?.type === 'string' && value.type.includes('#')) {
const promise = loadReference(value.type).then(fn => {
value.type = fn;
});
pendingPromises.push(promise);
}
return value;
});
await Promise.all(pendingPromises);
const primitives = interpret(lateComponents);const tree = perform(json);
document.body.appendChild(tree);When the Two Split Worlds Must Meet
Now suppose the Clock must display time captured in the early world, while a color around it is a decision that has to be made late — somewhere that prompt (asking the user for the color) exists. The full program is displayed as:
import tag { Clock } from './Clock';
export function App() {
return (
<div>
<Greeting />
<p>
The time is: <Clock />
</p>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}export function Clock() {
return new Date().toString();
}Simply lifting the Clock component up makes it run early, matching its data need. The surrounding <p>, though, requires the late result of prompt. Moving the entire App into the late world removes the prompt problem, but the early Clock function is lost:
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}export function App() {
// 🔴 ReferenceError: Greeting is not defined
// 🔴 ReferenceError: Clock is not defined
return (
<div>
<Greeting />
<p style={{
color: prompt('Pick a color:')
}}>
<Clock />
</p>
</div>
);
}Lifting the Greeting and Clock downward as well would so solve the split but breaks the rule that Clock must expose an early time. Since import tag can point only from the early world at the late, it cannot create a door moving up from a later world back to a former one.
What remains is to return a tag where one side is satisfied in the early world and the other in the late world. We need to nest rather than call: the future may not phone home, but the past can be packaged into the future by embedding an early tag within a late one:
import tag { Donut } from './Donut';
export function App() {
return (
<div>
<Greeting />
<Donut>
The time is: <Clock />
</Donut>
</div>
);
}
function Greeting() {
return (
<p>
Hello, <input placeholder="Who are you?" />
</p>
);
}
function Clock() {
return new Date().toString();
}export function Donut({ children }) {
return (
<p style={{
color: prompt('Pick a color:')
}}>
{children}
</p>
);
}This is no longer three separate function calls. It is two surrounding calls that sandwich an embedded element. The early component tree holds a fragment that depends on data from the late environment; the late component invocation wraps and completes that work, handing final DOM over to the platform. The two worlds do not talk to each other over an interrupt — their boundary is drawn as nesting, and all that is needed is the serializable tree, backticks, functions, and references. That is the complete picture for splitting code across both time and space.
What’s Left to Explore
A complete treatment of this architecture would fill a book, but a few promising directions are worth sketching for readers who want to push further.
Poison Pills
As your codebase matures, you'll want to stop reasoning about which world a given module belongs to and instead simply declare the capabilities it requires. For instance, if a database exists solely in the Early world, you'd benefit from a mechanism that makes importing it from the Late world a build error outright, preventing accidental bundling of server-side code. Node.js's custom user conditions offer a clean way to enforce these constraints.
Directives and Renaming Worlds
The import tag and import rpc forms are conceptually neat but awkward to use at scale. While the technical boundaries between worlds must remain strict, your mental model can shift to writing code as though the distinctions don't exist—poison pills catch mistakes, and you can move modules around freely, cutting new "doors" in response to build errors. When you do need to create such a door, it feels more natural to annotate it at the export site than at the import. You could (ab)use directive syntax for this purpose. Rename "Early" to something descriptive like "Server" and "Late" to "Client," and you're looking at 'use client' replacing import tag and 'use server' replacing import rpc—both familiar from React's RSC directives.
Async Thinking, Streaming, and State
The Early (or Server) world is well-suited for data fetching, given its deployability to low-latency environments. As an exercise, try making the "thinking" phase asynchronous; it requires only modest changes. Streaming execution offers another avenue: rather than running each phase to completion sequentially, you can interleave them since Components evaluate outside-in. Instead of waiting for an entire JSON tree of Client Components, a specialized wire format could place "holes" where computations haven't finished, later patching them with additional JSON as results arrive.
Introducing state makes Late (Client) Components dramatically more useful. Recall that a tag represents a potential call—it might not execute, or it could execute many times. When a Late Component's state changes, you can re-run just that component, leaving all Early Components untouched, which guarantees predictable, instant state updates.
Rethinking World Boundaries
The Early and Late worlds need not map to "server" and "client" exactly. If your Late Components are stateful, for example, you might host both worlds on a server. Here, the server invokes the Late world with initial state to produce an initial tree of Primitives—serializable to HTML—so users see content before any Client Components load on their device. Caching is another natural extension: the Early world can run ahead of time with results stored as static site generation, or you could add a Cache world to reuse computation across requests.
For hands-on exploration, run the final code example. If you'd rather jump straight into real-world usage without a framework, Parcel now supports React Server Components out of the box.



