## 1. Performance Audit
The core issue here is a classic **render-then-fetch waterfall**:
1. Router matches `/users/:id` → component tree mounts
2. `UserProfile` renders once with `user = null`
3. **Then** `useEffect` fires and the fetch starts
4. Browser paints "Loading..." with zero data
5. Fetch resolves → second render with data
The fetch doesn't start until *after* JS has parsed, React has committed the initial render, and the effect queue has flushed. That's 100–300ms of pure dead time on a cold navigation, and it compounds badly if this component has children that also fetch on mount (nested waterfalls). There's also no request deduplication, no error boundary, and no caching — navigating away and back re-fetches from scratch every time.
## 2. Architectural Refactor
Move to a data router with object-based route config so the loader kicks off
**in parallel with** route module fetching, not after component mount:
```tsx
// router.tsx
import { createBrowserRouter } from "react-router-dom";
export const router = createBrowserRouter([
{
path: "/users/:id",
lazy: async () => {
const { UserProfile, userLoader } = await import("./routes/UserProfile");
return { Component: UserProfile, loader: userLoader };
},
},
]);
```
```tsx
// routes/UserProfile.tsx
import { Suspense } from "react";
import { Await, useLoaderData, defer, type LoaderFunctionArgs } from "react-router-dom";
interface User {
id: string;
name: string;
}
export function userLoader({ params }: LoaderFunctionArgs) {
const userPromise = fetch(`/api/users/${params.id}`).then((res) => {
if (!res.ok) throw new Response("Not found", { status: res.status });
return res.json() as Promise;
});
return defer({ user: userPromise });
}
export function UserProfile() {
const { user } = useLoaderData() as { user: Promise };
return (
Loading...}>
Failed to load user.}>
{(resolved: User) => (
{resolved.name}
)}
);
}
```
**Key change:** the `fetch` call now lives in `loader`, which React Router invokes
**as soon as navigation is triggered** — before the destination component's JS chunk even finishes downloading, if you're using `lazy()`. `defer` means we don't block the transition on the fetch either; the route renders immediately and streams the user data in via ``.
## 3. Data & State Implementation
For the "instant transition, stream in data" behavior you want, `defer` is doing the heavy lifting. A few refinements worth adding:
**Revalidation control** — prevent unnecessary re-fetches on sibling route changes:
```tsx
export function shouldRevalidate({ currentParams, nextParams }: ShouldRevalidateFunctionArgs) {
return currentParams.id !== nextParams.id;
}
```
**Parallel data needs** — if `UserProfile` also needs, say, the user's recent activity, fetch both in the loader concurrently rather than nesting effects:
```tsx
export function userLoader({ params }: LoaderFunctionArgs) {
const userPromise = fetch(`/api/users/${params.id}`).then((r) => r.json());
const activityPromise = fetch(`/api/users/${params.id}/activity`).then((r) => r.json());
// Critical data (user) can be awaited if it's small/fast;
// secondary data (activity) stays deferred/streamed.
return defer({
user: userPromise,
activity: activityPromise,
});
}
```
**Abort on navigation away** — loaders receive a `request.signal`; pass it through so in-flight fetches cancel cleanly:
```tsx
export function userLoader({ params, request }: LoaderFunctionArgs) {
const userPromise = fetch(`/api/users/${params.id}`, { signal: request.signal })
.then((r) => r.json());
return defer({ user: userPromise });
}
```
If you're open to it, this is also the point where migrating to
**v7 framework mode** pays off — route modules give you `clientLoader` + server-rendered `loader` co-location and automatic request deduplication without hand-rolling a cache layer.
## 4. Build Tool Directives
For Webpack, to make sure the `lazy()` route above actually splits into its own chunk instead of getting bundled into `main.js`:
```js
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
// Isolate route-level chunks
routes: {
test: /[\\/]src[\\/]routes[\\/]/,
name(module) {
const match = module.context.match(/routes[\\/](.*?)([\\/]|$)/);
return `route-${match ? match[1] : "chunk"}`;
},
chunks: "async",
priority: 10,
},
// Separate vendor bundle so route chunks stay small
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
chunks: "all",
priority: -10,
},
},
},
},
};
```
Also confirm `import("./routes/UserProfile")` isn't accidentally eager — check your bundle analyzer (`webpack-bundle-analyzer`) after this change to verify `UserProfile` shows up as its own async chunk, not inlined into the entry point. If `Await`/`defer` pulls in extra `react-router-dom` internals you don't already use elsewhere, expect a small (~1-2kb gzip) bump in the route chunk — that's expected and worth it for the waterfall elimination.