The Limits of RSC: A Practitioner's Journey
React Server Components promised to revolutionize React application development. I embraced this vision fully, only to discover that real-world implementation exposes fundamental gaps in the model. This is my journey from RSC enthusiasm to practical compromise.
The Promise of React Server Components
React Server Components (RSC) introduced an elegant concept: render components on the server without sending their JavaScript to the client. Unlike traditional Server-Side Rendering (SSR), which renders entire pages and rehydrates them in the browser, RSC enables fine-grained control. Developers can seamlessly mix server components (no hydration, no JS on the client) with client components (interactive, hydrated), creating an “islands architecture” where each UI element uses the most appropriate rendering method.
RSC also supports asynchronous data fetching directly within server components, significantly improving the developer
experience compared to Next.js’s older getServerSideProps
API.
The benefits were compelling: serve static or non-interactive elements as plain HTML, reduce client-side bundle sizes, accelerate initial page loads, and cleanly separate concerns. What wasn’t to love?
RSC in Practice: Early Success
I went all-in on RSC for a project that seemed perfectly suited for it - predominantly static content, heavy markdown rendering, and significant benefits from server-side rendering. I carefully designed around RSC’s constraints:
- Created client components only at necessary interaction boundaries
- Passed server-generated props to client components instead of managing server state
- Implemented forms with server functions that revalidated API calls
- Placed React hooks strategically in leaf components or lower in the component tree
By following these patterns, development felt refreshingly straightforward. The application architecture was clean, and the initial performance metrics were impressive.
The Breaking Point: Implementing Infinite Scrolling
Then came infinite scrolling - a relatively common feature that exposed fundamental limitations in the RSC paradigm.
With infinite scrolling, you load chunks of data as the user scrolls to the end of a list rather than loading everything at once. This pattern requires maintaining state across multiple data fetches while preserving previously loaded items - something that proved remarkably difficult within RSC constraints.
The Limited Options for Client-Side Data Fetching in RSC
Option 1: Server Component Data Fetching with URL State Change
This approach involves navigating to a different URL with updated parameters for the current page. It works well for pagination where we can discard previous data and render only the new page.
However, the server component has no access to previously fetched data, meaning it must re-execute all previous requests. For infinite scrolling, fetching page 50 would require calling the backend 50 times (once for each page) - a highly inefficient approach that undermines the performance benefits RSC promised.
// @/app/(app)/test/[page]/page.tsx
import { FetchedPage } from "@/app/(app)/test/[page]/FetchedPage";
import { InfiniteListTrigger } from "@/app/(app)/test/[page]/InfiniteListTrigger";
interface Props {
params: Promise<{
page: string;
}>;
}
export default async function RootPage({ params }: Props) {
const { page: stringPage } = await params;
const page = Number(stringPage);
const pages = Array.from({ length: page }, (_, pageIndex) => {
return pageIndex + 1;
});
return (
<div>
{pages.map((page) => {
return <FetchedPage key={page} page={page} />;
})}
<InfiniteListTrigger page={page} />
</div>
);
}
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
export function InfiniteListTrigger({ page }: { page: number }) {
const router = useRouter();
const fetchMoreIntersectionElementRef = useRef<HTMLDivElement>(null);
const isFetchedRef = useRef(false);
useEffect(() => {
if (
fetchMoreIntersectionElementRef.current == null ||
isFetchedRef.current
) {
return;
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
isFetchedRef.current = true;
router.push(`/test/${String(page + 1)}`);
observer.disconnect();
}
});
observer.observe(fetchMoreIntersectionElementRef.current);
return () => {
observer.disconnect();
};
}, [page, router]);
return (
<div ref={fetchMoreIntersectionElementRef}>
<LoadingSpinner />
</div>
);
}
import { redirect } from "next/navigation";
import { getItemsFromDB } from "@/components/article/fetchPage";
export async function FetchedPage({ page }: { page: number }) {
const data = await getItemsFromDB(page);
if (data.length === 0) {
redirect(`/test/${(page - 1).toFixed(0)}/final`);
}
return data.map((item) => <div key={item.id}>{item.name}</div>);
}
// @/app/(app)/test/[page]/final/page.tsx
import { FetchedPage } from "@/app/(app)/test/[page]/FetchedPage";
interface Props {
params: Promise<{
page: string;
}>;
}
export default async function FinalPage({ params }: Props) {
const { page: stringPage } = await params;
const page = Number(stringPage);
const pages = Array.from({ length: page }, (_, pageIndex) => {
return pageIndex + 1;
});
return (
<div>
{pages.map((page) => {
return <FetchedPage key={page} page={page} />;
})}
</div>
);
}
Option 2: Render Server Function Return Value Using useActionState
This approach uses useActionState to call a server function that returns the next page and uses the result as state. This pattern is typically used to display structured information after submitting a form.
We can adapt this model: create a component that accepts the current page and item limit to fetch, then trigger a server action to fetch the next page when scrolling to the end. When the request resolves, it renders the data and another instance of itself with the next page as a parameter.
However, this approach has significant limitations:
- Server actions can’t be
GET
requests - They run serially and block each other (preventing user interaction during fetches)
- The implementation quickly becomes complex for shared or persistent state (the data model remains tied to the first page, and we can’t access all data in a single variable)
"use client";
import { useActionState, useEffect, useRef } from "react";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { fetchPage } from "./fetchPage";
export function InfiniteListPage({ page }: { page: number }) {
const [fetchPageState, fetchPageAction] = useActionState(fetchPage, null);
const fetchMoreIntersectionElementRef = useRef<HTMLDivElement>(null);
const isFetched = fetchPageState != null;
useEffect(() => {
if (isFetched || fetchMoreIntersectionElementRef.current == null) {
return;
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
fetchPageAction({ page });
observer.disconnect();
}
});
observer.observe(fetchMoreIntersectionElementRef.current);
return () => {
observer.disconnect();
};
}, [fetchPageAction, isFetched, page]);
return (
<>
{fetchPageState == null
? null
: fetchPageState.items.map((item) => {
return <div key={item.id}>{item.name}</div>;
})}
{isFetched ? null : (
<div ref={fetchMoreIntersectionElementRef}>
<LoadingSpinner />
</div>
)}
{fetchPageState?.hasMore ? (
<InfiniteListPage page={fetchPageState.nextPage} />
) : null}
</>
);
}
Breaking Up with RSC as a Data Layer
Infinite scrolling forced a critical decision. After evaluating the options, I migrated to @tanstack/react-query
.
React Query offers more control over query state and optimistic updates.
It provides a data layer
that acts as a single source of truth - unlike scattered useOptimistic
values or outdated server props
that don’t update on the client.
This centralized approach is especially valuable when multiple components rely on the same mutation, And unlike RSC, it gives you built-in tools to manage and update client-side state.
The transition required building a client-side state management layer—something RSC doesn’t provide.
State libraries and RSC props are completely disconnected,
so I couldn’t automatically trigger a React Query revalidation from a server action using revalidatePath
.
The RSC model runs on the server, while the TanStack Query model operates entirely on the client.
Each step took me further from the “RSC way” of building applications. But it worked.
Conclusion: The Future of React and RSC
RSC is not your data layer.
My experience taught me that while RSC offers significant advantages, even seemingly simple features like infinite scrolling can break the model.
I still use RSC for:
- Initial data-fetching with SSR
- Rendering static content like Markdown
- Performance optimization of non-interactive UI elements (like app shell)
But I no longer rely on it as my application’s data layer. Most real-world apps require client-state management, and transitioning away from RSC after adoption is challenging.
RSC represents an important advance in React development, but it works best as part of a hybrid approach rather than an all-encompassing solution. Use it where it shines, but be prepared to supplement it with client-side data management when complexity demands it.
As Evan You aptly observed:
I don’t think RSC itself being a React feature is a problem - it unlocks some interesting patterns.
The issue is the tradeoffs involved in making it work. It leaks into, or even demands control over layers that are previously not in scope for client-side frameworks. This creates heavy complexity (and associated mental overhead) in order to get the main benefits it promises.
React team made a bet that they can work with Next team to polish the DX to the extent that the benefit would essentially be free - so that RSC can be a silver bullet for all kinds of apps, and that it would become the idiomatic way to use React. IMO, that bet has failed.