Easy RAG for TypeScript and React Apps
RAG is not just for Pythonistas - it's easy and powerful with TypeScript and React too
Last week we looked at how React Server Component Payloads work under the covers. Towards the end of that article I mentioned a fascinating thing that you can do with RSC: sending unresolved promises from the server to the client. When I first read that I thought it was a documentation bug, but it's actually quite real (though with some limitations).
Here's a simple example of sending a promise from the server to the client. First, here's our server-rendered component, called SuspensePage in this case:
So we just imported a getData()
function that returns a promise that resolves after 1 second. This simulates a call to a database or other asynchronous action. Here's our fake getData()
function:
We pass that promise to a component called Table
as a prop called dataPromise
. Here's that component (note the use client
directive, which tells the compiler that this component will be run on the client):
dataPromise
is not a good name for a prop in reality, but I call it that here to make it clear that this is a Promise, not the data itself. We don't get the actual data until that Promise resolves.
Note that although React Server Components can be async functions, we're not actually writing our server-rendered SuspensePage
component using async, nor are we making the client-side Table
function an async one (that's partially because async components are not yet supported on the client side, but also partly because we don't need to).
use()
This component uses the new React use
hook to wait for the promise to resolve. use
accepts a Promise as an argument, and does some clever things:
Under the covers, in that second scenario (promise not yet resolved), use
will actually throw
the Promise, which is caught by React and used to suspend the component. This is how React knows to wait for the promise to resolve before rendering the component. That thrown Promise will be caught by the nearest Suspense
boundary, which will then show the fallback until the Promise resolves.
When the Promise does eventually resolve, React will re-render the component with the resolved value. If we used use()
multiple times, the pattern will repeat until all of the Promises have resolved and all of the components rendered (or until some of the Promises reject and the nearest Error Boundary renders).
The key to sending a Promise from the server to the client is to not await it on the server. If you await the Promise on the server, you'll be sending the resolved value to the client, not the Promise itself, but the Promise may take some time to resolve, during which UI rendering is blocked and your user left waiting.
As for how this actually works under the covers, take a look at my post on how React Server Component Payloads work, but the high level flow goes like this:
<script>
tag with the resolved value, keyed on the Promise ID it generated.The key to all this working is that the server is streaming the HTML response to the client, and doesn't actually close that stream until it has finished rendering everything, including resolved Promises. So in our case above, the server would likely render our very basic SuspensePage
component in a few milliseconds, but then keep the stream open for another second while it waits for the getData()
Promise to resolve.
At that point, thanks to the streamable nature of HTML, the server can just send a bit more response HTML in the form of that <script>
tag, which will trigger a next.js (in this case) callback that will update the client-side component with the resolved value.
I created a simple, live and hosted example of this on my RSC Examples site. You can see this example at https://rsc-examples.edspencer.net/promises/resolved. To view the example directly, skipping the explanation, take a look at https://rsc-examples.edspencer.net/examples/promises/resolved. If you run this curl command you can see the server response streaming in real time:
What you'll see there is the server sending the majority of the HTML response, pause for 1 second, then spit out a final <script>
tag that looks like this, along with a <div>
tag that we'll get to in a moment:
self.__next_f.push
is a next.js function that overrides the push
method on the __next_f
array, which under the covers fires a bunch of logic to handle whatever the server is sending. I cover that process in a lot more detail in this article about how React Server Component Payloads work, but at a high level: the ID that React generated for our dataPromise
Promise was ID=9, and so when this <script>
tag is executed, under the covers next.js will figure out that the resolved Promise with ID=9 needs to go back into the dataPromise
prop of the Table
component.
Now that the promise has resolved on the server, been streamed across to the client and then re-constituted as a Promise again, the client component re-renders, this time with a resolved dataPromise
and is therefore able to fully render. We really ended up with 2 Promises - one on the server, the other a reconstituted version of that Promise on the client, but in our code we can treat them as the same thing.
Now let's take a look at that <div>
tag that was also sent by the server (it also has a second <script>
tag tacked on there). I've formatted this slightly to make it more readable as it usually comes down in a single line:
So at the same time as the server sent the <script>
tag with the resolved Promise, it also sent this <div>
tag with the fully rendered table. This is a neat trick that next.js does to make the page load faster: it sends the server-rendered HTML along with the Promise, so that the client can instantly render the page with the server-rendered HTML, and then hydrate it with the resolved Promise when it arrives.
That second <script>
tag just defines a function that replaces an existing element on the page with id B:0
with the element with id S:0
. The S:0
element is the server-rendered table that just streamed down, and the B:0
element is a Suspense-rendered placeholder that allows React to drop this delayed content into the right place in the DOM. When the <Table>
initially attempted to render on the server, but was suspended due to the unresolved Promise, it rendered a placeholder instead of the actual table, with an ID of B:0
.
But you can't just send any Promise from the server to the client. The value that the Promise ultimately resolves has to be either a simple native data type like a string, number or float, or a plain JS object/array, or a rendered React component. If the Promise resolves to anything else, you'll get an error on the client side when React tries to render it.
I put together a second example at https://rsc-examples.edspencer.net/promises/various-datatypes that shows the ability to load a variety of different data types. Here's a video of that example in action:
Here we see strings, numbers, floats, plain objects and arrays being sent across the void, as well as a React component, which is a cool thing to be able to do. The React component is a simple one that just renders a string, but it could be anything you like. The full example is at https://rsc-examples.edspencer.net/promises/various-datatypes.
There is a separate example at https://rsc-examples.edspencer.net/promises/rendering-components that focuses on just a React component being rendered without all of the other types so you can see how that works in isolation.
RAG is not just for Pythonistas - it's easy and powerful with TypeScript and React too