TL;DR: After building complex RSC applications, I kept hitting the same wall: sharing server-side state without prop drilling or client contexts. So I built rsc-state, a library that uses React's cache API to make server-side state management painless.
The Problem Nobody Talks About
React Server Components are great for performance. Fetch data on the server, render HTML, ship minimal JavaScript. The pitch is compelling.
But here's what the tutorials don't show: what happens when multiple server components need the same data?
I was building a multi-step application (think wizards, configurators, multi-page forms). Each step was a Server Component fetching its own data. Clean architecture, right?
Then the requirements evolved:
- The header needs to show the user's current selections
- The sidebar needs to display a running total
- The footer needs to know which step is active
- Validation depends on selections from previous steps
Suddenly, I had three options... all bad.
The Three Anti-Patterns I Kept Falling Into
1. The Props Waterfall
The "correct" React way: pass everything down as props.
// layout.tsx
export default async function Layout({ children }) {
const user = await getUser();
const preferences = await getPreferences();
const config = await getConfig();
return (
<Wrapper user={user} preferences={preferences} config={config}>
{children}
</Wrapper>
);
}
Then every child needs those props. And their children need them. And so on.
<StepContainer {...props}>
<StepHeader {...props} />
<StepContent {...props} />
<StepFooter {...props} />
</StepContainer>
I ended up with prop types that looked like this:
type StepProps = WithAuth<WithConfig<WithPreferences<BaseProps>>>;
Spread operators everywhere. Every component became a prop relay station. Adding a new piece of shared state meant touching dozens of files.
2. The "use client" Infection
The escape hatch: wrap everything in a client context.
"use client";
const AppContext = createContext(null);
export function AppProvider({ children, initialData }) {
const [state, setState] = useState(initialData);
// ...
}
This works, but look what happens:
- Every component that reads from the context needs
"use client" - Those components can't be Server Components anymore
- Bundle size grows
- You lose the SSR benefits you wanted in the first place
On one project, I had 100+ files importing the same context hook. Every single one required the client directive. The RSC architecture was compromised by a pattern designed for client-side React.
3. The Context Complexity Explosion
The real killer: when your client context grows to handle all the edge cases.
State that started simple:
const [selections, setSelections] = useState({});
Became this:
const [selections, setSelections] = useState({});
const [derived, setDerived] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [cache, setCache] = useState(new Map());
const [pending, setPending] = useState(false);
// ... and more
const selectionRef = useRef(null);
const cacheRef = useRef(new LRUCache());
const abortRef = useRef(null);
// ... and more
useEffect(() => {
/* sync to URL */
}, [selections]);
useEffect(() => {
/* validate */
}, [selections]);
useEffect(() => {
/* compute derived */
}, [selections]);
// ... and more
I've seen context files grow to 1,000+ lines. Fifteen useState hooks. Ten useRef hooks. Complex caching, retry logic, URL synchronization... all crammed into a client component because there was no good way to share state on the server.
The Missing Primitive
React has cache(). It's designed for request-scoped memoization in Server Components:
const getUser = cache(async (id: string) => {
return await db.user.findById(id);
});
// Call it anywhere in the same request with the same args
const user1 = await getUser("123");
const user2 = await getUser("123"); // Cache hit, no refetch
The cache is automatically invalidated between server requests, so each user gets isolated data. But cache() is designed for memoizing function calls, not for managing mutable state. There's no update mechanism, no derived state, no lifecycle hooks.
So I built the abstraction layer.
The Solution: Request-Scoped Stores
// stores/user.ts
import { createServerStore } from "rsc-state";
export const userStore = createServerStore({
initial: {
userId: null as string | null,
name: "",
},
derive: (state) => ({
isAuthenticated: state.userId !== null,
}),
});
Initialize once in your layout:
// app/layout.tsx
export default async function Layout({ children }) {
const session = await getSession();
userStore.initialize({
userId: session?.userId ?? null,
name: session?.name ?? ""
});
return <>{children}</>;
}
Read from any Server Component (no props, no hooks, no client directive):
// components/Header.tsx (Server Component!)
export function Header() {
const { name, isAuthenticated } = userStore.read();
return (
<header>
{isAuthenticated ? `Welcome, ${name}` : "Welcome, Guest"}
</header>
);
}
// components/Sidebar.tsx (Also a Server Component!)
export function Sidebar() {
const { isAuthenticated } = userStore.read();
if (!isAuthenticated) return null;
return <nav>...</nav>;
}
No prop drilling. No context providers. No "use client" just to read shared state.
What Changes in Practice
Before: Multi-Step Form with Client Context
Layout (fetches user, passes to context)
├── StepsProvider ("use client", 800+ lines)
│ ├── Header ("use client", imports useStepsContext)
│ ├── Sidebar ("use client", imports useStepsContext)
│ ├── StepContent ("use client", imports useStepsContext)
│ │ ├── StepForm ("use client", imports useStepsContext)
│ │ └── StepPreview ("use client", imports useStepsContext)
│ └── Footer ("use client", imports useStepsContext)
After: Multi-Step Form with Server Store
Layout (fetches user, initializes store)
├── Header (Server Component, calls store.read())
├── Sidebar (Server Component, calls store.read())
├── StepContent (Server Component, calls store.read())
│ ├── StepForm (Client Component only where needed)
│ └── StepPreview (Server Component, calls store.read())
└── Footer (Server Component, calls store.read())
The difference:
- Most components stay as Server Components
- Only interactive parts need "use client"
- No context provider wrapper
- No props flowing through every level
The API
Creating a Store
const store = createServerStore({
initial: { count: 0 },
// Optional: computed values (memoized)
derive: (state) => ({
doubled: state.count * 2,
isPositive: state.count > 0,
}),
// Optional: lifecycle hooks
onInitialize: (state) => console.log("Store ready:", state),
onUpdate: (prev, next) => console.log("Changed:", prev, "→", next),
});
Reading State
// Get everything
const state = store.read();
// Select specific values
const count = store.select((s) => s.count);
Updating State
// Replace entirely
store.set({ count: 5 });
// Update with reducer
store.update((prev) => ({ ...prev, count: prev.count + 1 }));
// Batch multiple updates (derived state computed once at the end)
store.batch((api) => {
api.update((s) => ({ ...s, count: s.count + 1 }));
api.update((s) => ({ ...s, count: s.count + 1 }));
});
Two Storage Modes
Request storage (default): State is isolated per request. Safe for user-specific data.
const userStore = createServerStore({
storage: "request", // default
initial: { userId: null },
});
Persistent storage: State shared across all requests. Use for feature flags, global config, demos.
const featureFlags = createServerStore({
storage: "persistent",
initial: { darkMode: false, betaFeatures: true },
});
Why Request-Scoped Matters
A common concern with "server state" is turning your stateless server into a stateful one. That's a valid worry... stateful servers are harder to scale, harder to deploy, and prone to memory leaks.
But request-scoped storage sidesteps this entirely. The state exists only for the duration of a single request. When the response is sent, it's gone. No cleanup, no memory growth, no cross-request pollution.
Your server remains stateless. Each request is independent. You can still:
- Scale horizontally without session affinity
- Deploy without draining connections
- Run serverless without cold-start state issues
The "persistent" storage mode does introduce true server state (stored in module-level variables), which is why it comes with warnings in the docs. But the default request-scoped mode? It's just memoization with a nice API.
When to Use This
Good fit:
- Server-rendered apps with shared request data (auth, config, preferences)
- Multi-step flows where state needs to be readable across components
- Eliminating prop drilling without sacrificing RSC benefits
- Apps where most components should remain Server Components
Not the right fit:
- Highly interactive UIs with frequent client-side updates (use Zustand, Jotai)
- State that needs to persist across navigations (use cookies, database)
- Simple apps where props work fine
How It Works
Under the hood, rsc-state uses React's cache() to create request-scoped singletons. The trick is wrapping a factory function that returns a mutable object:
// Simplified internal implementation
const getRequestScopedInstance = cache(() => ({
state: initialState,
initialized: false,
derivedCache: null,
}));
// Every call within the same request returns the same object
function read() {
const instance = getRequestScopedInstance();
return instance.state;
}
Since cache() memoizes per request, all components calling getRequestScopedInstance() get the same object reference. Mutations to that object are visible everywhere within that request, but isolated from other requests.
The library adds:
- Type-safe derived state that only recomputes when dependencies change
- Lifecycle hooks for logging, analytics, side effects
- Error boundaries so a broken derive function doesn't crash your app
- Batch updates to minimize recomputation
Try It
npm install rsc-state
import { createServerStore } from "rsc-state";
// Create
const store = createServerStore({
initial: { items: [] as string[] },
derive: (state) => ({
count: state.items.length,
isEmpty: state.items.length === 0,
}),
});
// Initialize (in layout)
store.initialize({ items: ["first"] });
// Read (in any server component)
const { items, count, isEmpty } = store.read();
Working examples for Next.js 14, 15, and 16 are in the repo.
What I Learned
RSC state management is fundamentally different from client state management. The patterns we've internalized (Context API, custom hooks, client-side stores) don't translate cleanly.
React gives us the primitives (cache, Server Components, Server Actions), but the ergonomics need work. This library is my attempt to bridge that gap.
If you've wrestled with similar problems, I'd love to hear your approaches. And if you try the library, open issues... I'm actively improving the API based on real-world usage.
Top comments (3)
This hits the exact scaling pain point with RSC — shared state without turning everything into a client context or creating a props waterfall. Using cache() as a request-scoped store is a clever abstraction, especially with derived state and batch updates. Keeping Server Components pure while still coordinating state is a real gap today. Nice work, looking forward to trying rsc-state in a multi-step flow. 👌🔥
Great article 👍🏼, what do you think about using Next.js in a blog or chat room?
I think Next is a great fit for both a blog and a chat room. A lot of the structure in those apps maps naturally to RSC: layouts, posts, metadata, user info, room state. Most of that is server data, so you keep your client bundle small and let the server handle the heavy parts.
The main thing to watch out for is what I mention in the post. Once the project grows, it becomes very easy to blur the line between server and client, and suddenly you have props flowing everywhere or a giant client context that forces half your tree into “use client”. Staying organized pays off a lot here.
For things like auth, user preferences, active room, or shared config, RSC makes life much easier if you keep the state on the server and only use client components where interactivity is required. If you end up trying the library, tell me how it goes, I’m curious how it feels in a chat-style app.