decisions/0004-streaming-apis.md
Date: 2022-07-27
Status: accepted
Remix aims to provide first-class support for React 18's streaming capabilities. Throughout the development process we went through many iterations and naming schemes around the APIs we plan to build into Remix to support streaming, so this document aims to lay out the final names we chose and the reasons behind it.
It's also worth nothing that even in a single-page-application without SSR-streaming, the same concepts still apply so these decisions were made with React Router 6.4.0 in mind as well - which will support the same Data APIs from Remix.
Streaming in Remix can be thought of as having 3 touch points with corresponding APIs:
loader can be done by returning a defer(object) call from your loader in which some of the keys on object are Promise instancesuseLoaderData
defer() response from your loader, you'll get Promise values inside your useLoaderData object 👌Promise from useLoaderData() with the <Await resolve={data.promise}> component<Await> accepts an errorElement prop to handle error UI<Await> should be wrapped with a <React.Suspense> component to handle your loading UIIn the spirit of #useThePlatform we've chosen to leverage the Promise API to represent these "eventually available" values. When Remix receives a defer() response back from a loader, it needs to serialize that Promise over the network to the client application (prompting Jacob to coin the phrase "promise teleportation over the network" 🔥).
In order to initiate a streamed response in your loader, you can use the defer() utility which accepts a JSON object with Promise values from your loader.
export async function loader() {
return defer({
// Await this, don't stream
critical: await fetchCriticalData(),
// Don't await this - stream it!
lazy: fetchLazyData(),
});
}
By not using await on fetchLazyData() Remix knows that this value is not ready yet but eventually will be and therefore Remix will leverage a streamed HTTP response allowing it to send up the resolved/rejected value when available. Essentially serializing/teleporting that Promise over the network via a streamed HTTP response.
Just like json(), the defer() will accept a second optional responseInit param that lets you customize the resulting Response (i.e., in case you need to set custom headers).
The name defer was settled on as a corollary to <script defer> which essentially tells the browser to "fetch this script now but don't delay document parsing". In a similar vein, with defer() we're telling Remix to "fetch this data now but don't delay the HTTP response".
We decided not to support naked objects due to the ambiguity that would be introduced:
// NOT VALID CODE - This is just an example of the ambiguity that would have
// been introduced had we chosen to support naked objects :)
// This would NOT be streamed
function exampleLoader1() {
return Promise.resolve(5);
}
// This WOULD be streamed
function exampleLoader2() {
return {
value: Promise.resolve(5),
};
}
// This would NOT be streamed
function exampleLoader3() {
return {
value: {
nested: Promise.resolve(5),
},
};
}
No new APIs are needed for the "Accessing" stage 🎉. Since we've "teleported" these promises over the network, you can access them in your components just as you would with any other data returned from your loader. This value will always be a Promise, even after it's been settled.
function Component() {
const data = useLoaderData();
// data.critical is a resolved value
// data.lazy is a Promise
}
In order to render your Promise values from useLoaderData(), Remix provides a new <Await> component which handles rendering the resolved value, or propagating the rejected value through an errorElement or further upwards to the Route-level error boundaries. In order to access the resolved or rejected values, there are two new hooks that only work in the context of an <Await> component - useAsyncValue() and useAsyncError().
This examples shows the full set of render-time APIs:
function Component() {
const data = useLoaderData(); // data.lazy is a Promise
return (
<React.Suspense fallback={<p>Loading...</p>}>
<Await resolve={data.lazy} errorElement={<MyError />}>
<MyData />
</Await>
</React.Suspense>
);
}
function MyData() {
const value = useAsyncValue(); // Get the resolved value
return <p>Resolved: {value}</p>;
}
function MyError() {
const error = useAsyncError(); // Get the rejected value
return <p>Error: {error.message}</p>;
}
Note that useAsyncValue and useAsyncError only work in the context of an <Await> component.
The <Await> name comes from the fact that for these lazily-rendered promises, we're not await-ing the promise in our loader, so instead we need to <Await> the promise in our render function and provide a fallback UI. The resolve prop is intended to mimic how you'd await a resolved value in plain Javascript:
// This JSX:
<Await resolve={promiseOrValue} />;
// Aims to resemble this Javascript:
const value = await Promise.resolve(promiseOrValue);
Just like Promise.resolve can accept a promise or a value, <Await resolve> can also accept a promise or a value. This is really useful in case you want to AB test defer() responses in the loader - you don't need to change the UI code to render the data.
export async function loader({ request }: LoaderArgs) {
const shouldAwait = isUserInTestGroup(request);
return {
maybeLazy: shouldAwait ? await fetchData() : fetchData(),
};
}
function Component() {
const data = useLoaderData();
// No code forks even if data.maybeLazy is not a Promise!
return (
<React.Suspense fallback={<p>Loading...</p>}>
<Await resolve={data.maybeLazy} errorElement={<MyError />}>
<MyData />
</Await>
</React.Suspense>
);
}
Additional Notes on <Await>
If you prefer the render props pattern, you can bypass useAsyncValue() and just grab the value directly:
<Await resolve={data.lazy}>{(value) => <p>Resolved: {value}</p>}</Await>
If you do not provide an errorElement, then promise rejections will bubble up to the nearest Route-level error boundary and be accessible via useRouteError().
With the presence of the <Await> component in React Router and because the Promise's don't have to be serialized over the network - you can technically just return raw Promise values on a naked object from your loader. However, this is strongly discouraged because the router will be unaware of these promises and thus won't be able to cancel them if the user navigates away prior to the promise settling.
By forcing users to call the defer() utility, we ensure that the router is able to track the in-flight promises and properly cancel them. It also allows us to handle synchronous rendering of promises that resolve prior to other critical data. Without the defer() utility these raw Promises would need to be thrown by the <Await> component to the <Suspense> boundary a single time to unwrap the value, resulting in a UI flicker.