In React Router 7's data mode, how do you render a post immediately while its slow comments section streams in, now that defer is gone?
answer
- don't await everything in the loader
- start the slow request first
- a promise inside the returned object
- Suspense boundary around a resolver
basics
~20 sIn React Router 7, the loader awaits only the post and returns the unawaited comments promise as a property of its result; the component renders <Await resolve={comments}> inside a <Suspense> fallback, so the post shows at once and comments fill in later.
solid answer
~40 sReact Router 7 removed `defer()`; you now return raw promises. In the loader I start the comments request without awaiting it, await only the post, and return `{ post, comments }`. The router resolves the loader as soon as that object is returned, so the route renders with the post. In the component I put `<Await resolve={comments}>` inside `<Suspense fallback={...}>`; `<Await>` suspends until the promise settles and then renders its children with the value, either through a render function or `useAsyncValue()` in a child. I give `<Await>` an `errorElement` and read the reason with `useAsyncError()`, so a comments failure does not take down the page. The promise must be a property of the returned object: returning a bare promise just makes the loader await it.
code
ts · 7 linesimport { getComments, getPost } from "./api";
export async function postLoader({ params }: { params: { postId?: string } }) {
const comments = getComments(params.postId!); // started, not awaited
const post = await getPost(params.postId!); // critical data
return { post, comments };
}go deeper
Recall that v7 streams by returning an unawaited promise inside the loader's object and reading it with Await inside Suspense.
Explain why a bare returned promise is awaited, why request order matters, and how errorElement and useAsyncError keep a failure local.
Decide which data deserves streaming, weighing the extra fallback and layout shift against the wait, and plan the v6 defer migration.
Set team guidance on critical versus deferred data per route and how it interacts with any caching layer in front of loaders.
## The problem A post page has two kinds of data. The **post body** is critical: the page is meaningless without it, and it usually comes back fast. The **comments** are secondary and slow. If the loader awaits both, the whole navigation waits for the slowest request, and the user stares at the previous page. What you want is: render the post as soon as it is ready, show a placeholder where the comments go, and fill them in when they arrive. React Router 6.4+ solved this with a `defer()` wrapper around the loader's return value. **React Router 7 removed `defer`** (along with `json`); the capability did not go away, it just no longer needs a wrapper. ## The v7 pattern, step by step 1. **Start the slow request first, without awaiting it**: `const comments = getComments(id);` This kicks off the network call and keeps the promise. 2. **Await only the critical data**: `const post = await getPost(id);` Because the comments request is already running, the two overlap. 3. **Return an object that contains the pending promise**: `return { post, comments };` The router resolves the loader as soon as the object is returned; it does not await promises nested inside it. 4. **In the component, wrap the slow part in `<Suspense>` and `<Await>`**: `<Suspense fallback={<CommentsSkeleton />}><Await resolve={comments}>{(list) => <Comments items={list} />}</Await></Suspense>`. 5. The route renders immediately with the post and the fallback; when the promise resolves, `<Await>` renders its children with the value. The ordering in steps 1 and 2 matters. Writing `const post = await getPost(id); const comments = getComments(id);` still streams the comments, but their request only starts after the post has arrived, so the comments appear later than they need to. ## Why the promise must sit inside an object A loader is an `async` function, and returning a promise from an `async` function **adopts** it: the loader's own promise does not settle until the returned one does. So `return getComments(id)` is simply awaited, and the whole route waits. Only a promise that is a **property** of the returned object survives as a pending promise for `<Await>` to consume. The React Router docs state it directly: you cannot return a single promise, it must be an object with keys. ## Ways to read the resolved value | Approach | How it reads the value | Notes | |---|---|---| | `<Await resolve={p}>{(v) => ...}</Await>` | render-function child | the common form | | `<Await resolve={p}><Comments /></Await>` + `useAsyncValue()` | hook inside the child | keeps the child reusable | | `use(p)` in a child component (React 19) | React's own hook | needs a separate component receiving the promise | `<Await>` **suspends** while its promise is pending, so it needs a `<Suspense>` boundary above it; the `fallback` of that boundary is what the user sees in the meantime. ## Error handling for the streamed part - Give `<Await>` an **`errorElement`** to render if the promise rejects. Inside it, **`useAsyncError()`** returns the rejection reason, so the message can say "comments failed to load" while the post stays on screen. - Without an `errorElement`, the rejection **bubbles to the nearest route error boundary** and is readable with `useRouteError()`. That replaces the whole route's element, which is usually too much for a secondary panel. - Errors in the **awaited** part (the post) behave like any loader error: they skip `<Await>` entirely and go to the nearest route error boundary. ## Scope notes - In **data mode** everything runs in the browser, so "streaming" here means the route renders before all of its data has settled. Server-side streaming of HTML, the server's stream timeout and framework-mode route modules are a different layer, owned by the framework-mode material. - The shape of `useLoaderData()` follows what the loader returned: `post` is a plain value and `comments` is a `Promise`, and `useLoaderData<typeof loader>()` types them that way. - Pending navigation state is unaffected by the streamed part: `useNavigation()` returns to `"idle"` once the critical data has loaded, even while `<Await>` is still showing its fallback. - Deferring everything is not a goal. Each streamed promise adds a fallback and a layout shift; keep it for data that is slow **and** not needed for the first meaningful paint.
- In React Router 7, what renders if the comments promise rejects and the <Await> has no errorElement?The rejection bubbles to the nearest route error boundary, so that route's `errorElement` or `ErrorBoundary` replaces its element and can read the reason with `useRouteError()`. The post disappears along with the comments, which is why a local `errorElement` on `<Await>` is the usual choice for a secondary panel.
- In React Router 7, how would you migrate a v6 loader that returns defer({ post: await getPost(id), comments: getComments(id) })?Drop the wrapper and return the object itself: `return { post, comments }`, with the comments promise still unawaited. `<Await>` and `<Suspense>` in the component stay as they were. While migrating, also start the slow request before awaiting the critical one so the two requests overlap.
saying these in an interview costs you the question
- Returns the slow promise directly from the loader and expects it to stream.
- Awaits the critical data first and only then starts the slow request.
- Renders <Await> without a Suspense boundary above it.
- Still imports defer from react-router in v7 code.
- Believes a rejected streamed promise is silently ignored by Await.