React 19 Is Already Here. You're Just Not Ready for It.
And that's not an insult. It's the most honest thing I can say.
React 19 dropped stable in December 2024. The React Compiler — what you might know as "React Forget" — hit beta the same month. React Server Components have been in production via Next.js since 2023. The feature set is real. The ecosystem is catching up. The question isn't "is React 19 ready?" anymore.
The question is: are you ready to understand what it actually means?
Because here's the uncomfortable truth most articles won't tell you: React 19 doesn't forgive ignorance. If you don't understand why re-renders happen, the Compiler won't save you. If you don't understand the server-client boundary, RSCs will break your app in ways that take hours to debug. This isn't React asking you to re-learn everything — it's React asking you to finally understand the fundamentals you skipped.
This article is for the senior who's been doing React for 5 years but never actually understood what useMemo costs. It's for Bishoy at 23, who would have wasted two weeks on this without someone honest to explain it.
After reading this, you will be able to:
- Explain the React Compiler to your Tech Lead without hand-waving
- Know exactly when to use Server Components and when they'll blow up in your face
- Debug the most common RSC mistakes before they hit production
- Make a confident, specific decision about whether to migrate today or wait
Let's get into it.
THE SETUP — Why React 19, Why Now
React has always had one fundamental problem. It's not a bug. It's a feature that became its own worst enemy: the re-render.
Every time state changes in a React component, React re-renders that component and — by default — every component below it in the tree. This is fine when your component tree is small. It becomes a production nightmare when you have a dashboard with 200+ components, complex state management, and users who notice when things feel slow.
The traditional solution was manual memoization: React.memo for components, useMemo for computed values, useCallback for functions passed as props. Developers learned this the hard way — usually after their first profiling session revealed that one parent component was causing 80% of their re-renders.
Here's what nobody tells juniors: memoization has a cost. Every useMemo call allocates memory and runs a comparison check on every render, even when it's not needed. Used incorrectly, you can make your app slower by trying to optimize it. I've done this.
React 19 addresses this problem from two different directions simultaneously. The Compiler handles the memoization problem. RSCs handle the data fetching problem. And a set of new hooks — useActionState, useFormStatus, useOptimistic — handle the async interaction problem.
This isn't a version bump. This is React paying off years of technical debt it accumulated from its own success.
THE BREAKDOWN — The Problem React 19 Is Actually Solving
Before I explain what React 19 does, I need to show you what breaks without it. This is the pattern I've seen in every React codebase I've touched in 15 years — from the IoT dashboards I was building in 2014 to the enterprise security panels I work on today.
The Re-render Waterfall
// This is a real pattern. I've seen it in production.
// It looks innocent. It is not.
function Dashboard() {
const [searchText, setSearchText] = useState('');
const [selectedFilter, setSelectedFilter] = useState('all');
return (
<div>
<SearchBar
value={searchText}
onChange={setSearchText}
/>
<FilterPanel
selected={selectedFilter}
onFilterChange={setSelectedFilter}
/>
{/* This re-renders on EVERY searchText change */}
{/* Even though it only cares about selectedFilter */}
<ExpensiveDataTable
filter={selectedFilter}
search={searchText}
/>
</div>
);
}
Every time the user types a character in SearchBar, the entire Dashboard re-renders. FilterPanel re-renders. ExpensiveDataTable re-renders. If ExpensiveDataTable has 50 child rows, each of those re-renders too.
The traditional fix:
// The "optimized" version — before React 19
function Dashboard() {
const [searchText, setSearchText] = useState('');
const [selectedFilter, setSelectedFilter] = useState('all');
// Memoize the search handler so SearchBar doesn't
// cause unnecessary re-renders downstream
const handleSearchChange = useCallback((text: string) => {
setSearchText(text);
}, []); // Empty deps — this function never changes
const handleFilterChange = useCallback((filter: string) => {
setSelectedFilter(filter);
}, []); // Same here
// Memoize the filtered data computation
const tableProps = useMemo(() => ({
filter: selectedFilter,
search: searchText,
}), [selectedFilter, searchText]);
return (
<div>
<SearchBar
value={searchText}
onChange={handleSearchChange} // Stable reference
/>
<FilterPanel
selected={selectedFilter}
onFilterChange={handleFilterChange} // Stable reference
/>
<MemoizedDataTable {...tableProps} />
</div>
);
}
// And you need this too:
const MemoizedDataTable = React.memo(ExpensiveDataTable);
This works. But look at what you had to do:
- Add
useCallbacktwice - Add
useMemoonce - Wrap the child component in
React.memo - Think carefully about dependency arrays
- Pray you didn't miss a dependency
And this is a simple example. In production, with 10 developers working on the same codebase, this pattern breaks constantly. Someone forgets React.memo. Someone adds a prop to the dependency array that shouldn't be there. Someone passes an inline object as a prop and breaks memoization silently.
This is the problem React 19's Compiler exists to solve.
THE MATCH REPORT — What React 19 Actually Changes
1, 2, 3, Tap — The React Compiler
The React Compiler (previously called React Forget) is a build-time tool that analyzes your component code and automatically inserts memoization where it's needed. You write clean, obvious code. The compiler makes it performant.
// What YOU write with React Compiler enabled:
function Dashboard() {
const [searchText, setSearchText] = useState('');
const [selectedFilter, setSelectedFilter] = useState('all');
// No useCallback. No useMemo. No React.memo.
// Just... code.
return (
<div>
<SearchBar
value={searchText}
onChange={(text) => setSearchText(text)} // Inline function — FINE now
/>
<FilterPanel
selected={selectedFilter}
onFilterChange={(f) => setSelectedFilter(f)} // Also fine
/>
<ExpensiveDataTable
filter={selectedFilter}
search={searchText}
/>
</div>
);
}
The compiler looks at this code and figures out: "The inline function (text) => setSearchText(text) doesn't depend on anything outside Dashboard that changes. I can make it stable." It inserts memoization at compile time. The output is performant code without you having to think about it.
But here's what the tutorials don't say — and this is the Red Card moment:
Red Card 🟥 — The Compiler only works if your code follows React's rules.
The Rules of React aren't just guidelines. They're the contract the Compiler depends on. If you violate them, the Compiler silently opts that component out of optimization. Your code still runs. It's just not optimized. And you have no idea.
The three rules that trip up the most developers:
Rule 1: Components must be pure — same inputs, same output. Side effects only in useEffect.
// 🚫 This breaks the Compiler's analysis:
let globalCounter = 0;
function BadComponent({ name }: { name: string }) {
globalCounter++; // Mutation of external variable
return <div>{name} — render #{globalCounter}</div>;
}
// ✅ This is what the Compiler can optimize:
function GoodComponent({ name, renderCount }: { name: string; renderCount: number }) {
return <div>{name} — render #{renderCount}</div>;
}
Rule 2: Hooks rules — no conditional hooks, no hooks in loops. The Compiler depends on the hook call order being stable.
Rule 3: Don't mutate props or state — always create new objects/arrays.
// 🚫 The Compiler cannot optimize components that do this:
function BadList({ items }: { items: Item[] }) {
items.push({ id: 'temp', name: 'Loading...' }); // Mutation!
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// ✅ Create new arrays:
function GoodList({ items }: { items: Item[] }) {
const displayItems = [...items, { id: 'temp', name: 'Loading...' }];
return <ul>{displayItems.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
The Compiler is not magic. It's a very smart tool that rewards clean code.
If your codebase has violations — and most production codebases do — you need to fix them before the Compiler helps you. The good news: the React team ships an ESLint plugin that surfaces violations. Run it. Fix what it finds. Then enable the Compiler.
How to Enable It Today
npm install -D babel-plugin-react-compiler
# or with Next.js 15+:
# It's already in the experimental config
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
// For non-Next.js projects, babel.config.js:
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// Start with specific files to test
sources: (filename) => filename.includes('src/components'),
}],
],
};
My recommendation: Don't enable it globally on day one. Enable it file by file, watch your bundle analyzer, run your tests. The Compiler is in beta. It's stable enough to use, but you want to be able to isolate issues.
2. React Server Components — The Thing Most Developers Get Wrong
RSCs are the most powerful and the most misunderstood feature in the React 19 ecosystem. Let me explain them the way I wish someone had explained them to me.
The mental model shift:
Before RSCs, there were two ways to get data into your component:
- Fetch in
useEffect(client-side, after render) - Fetch in
getServerSidePropsorgetStaticProps(Next.js patterns, tied to pages)
Both have problems. useEffect fetching causes waterfalls — component A renders, fires a request, waits, then renders component B, which fires another request. You've seen this: the spinning spinner on top of a spinning spinner.
getServerSideProps solves the waterfall but creates a new problem: it's page-level only. If your data is needed by a deeply nested component, you have to pass it through 5 layers of props, or put it in global state. Neither is clean.
RSCs give components the ability to fetch their own data on the server, in parallel, before anything reaches the client.
// @/app/dashboard/page.tsx
// This is a Server Component by default in Next.js App Router
import { getUserMetrics } from '@/lib/db';
import { getActiveAlerts } from '@/lib/alerts';
import MetricsPanel from '@/components/MetricsPanel';
import AlertsPanel from '@/components/AlertsPanel';
import FilterControls from '@/components/FilterControls'; // Client Component
export default async function DashboardPage() {
// These run IN PARALLEL on the server
// No waterfall. No loading state. No useEffect.
const [metrics, alerts] = await Promise.all([
getUserMetrics(),
getActiveAlerts(),
]);
return (
<div className="dashboard">
{/* Server Component — no JS shipped to client */}
<MetricsPanel data={metrics} />
{/* Server Component — no JS shipped to client */}
<AlertsPanel alerts={alerts} />
{/* Client Component — needs interactivity */}
<FilterControls />
</div>
);
}
MetricsPanel and AlertsPanel are Server Components. They render on the server, produce HTML, and ship zero JavaScript to the client. The browser receives rendered HTML for those components. Instant paint. No hydration delay.
FilterControls needs client-side interactivity — state, event handlers — so it's a Client Component. It gets hydrated in the browser.
The bundle size impact is real. In a project I was analyzing recently, moving data-display components to Server Components reduced the client bundle by 34%. Components that imported heavy libraries (date formatters, chart utilities) for display-only purposes now ran those imports exclusively on the server. The client never downloaded them.
The Server-Client Boundary — Where Everything Goes Wrong
This is where I need to slow down and be precise, because this is where developers spend hours debugging.
The boundary rule: A Server Component can import and render Client Components. A Client Component cannot import Server Components.
// ✅ This works — Server Component rendering a Client Component:
// ServerComponent.tsx (no 'use client' directive)
import ClientButton from './ClientButton'; // Client Component
export default async function ServerComponent() {
const data = await fetchData();
return (
<div>
<p>{data.title}</p>
<ClientButton label="Click me" /> {/* Fine */}
</div>
);
}
// 🚫 This BREAKS — Client Component trying to use a Server Component:
'use client';
// ClientWrapper.tsx
import ServerDataFetcher from './ServerDataFetcher'; // Server Component
export default function ClientWrapper() {
const [count, setCount] = useState(0);
return (
<div>
<ServerDataFetcher /> {/* This will error. Server Component inside Client. */}
<button onClick={() => setCount(c => c + 1)}>{count}</button>
</div>
);
}
The error message you'll see: Error: async/await is not yet supported in Client Components, only Server Components.
It's confusing because the reason is that Server Components are async by nature — they can await promises. Client Components can't run async code at the top level. When you try to nest a Server Component inside a Client Component, React doesn't know how to handle the async boundary.
The fix: Pass server data as props, or pass Server Components as children.
// ✅ Pattern 1: Pass data down as props
// ServerParent.tsx
import ClientWrapper from './ClientWrapper';
export default async function ServerParent() {
const data = await fetchData(); // Runs on server
return <ClientWrapper initialData={data} />; // Pass as prop
}
// ClientWrapper.tsx
'use client';
export default function ClientWrapper({ initialData }) {
const [data, setData] = useState(initialData);
// Now you have client-side state AND server-fetched initial data
return <div>{/* render data */}</div>;
}
// ✅ Pattern 2: Pass Server Components as children (the elegant solution)
// ServerParent.tsx
import ClientWrapper from './ClientWrapper';
import ServerDisplay from './ServerDisplay';
export default async function ServerParent() {
return (
<ClientWrapper>
<ServerDisplay /> {/* Server Component passed as children prop */}
</ClientWrapper>
);
}
// ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* ServerDisplay renders here, still on server */}
</div>
);
}
Pattern 2 is the one most tutorials skip. It feels strange at first — how can a Client Component render a Server Component? The answer: it doesn't. The Server Component was already rendered on the server. The Client Component is just deciding where to place the already-rendered output.
When NOT to Use Server Components
This is the part I want tattooed somewhere visible.
Don't use Server Components for:
Anything with event handlers —
onClick,onChange,onSubmit. These require client-side JS. Server Components can't have them.Anything with React state —
useState,useReducer. No state on the server.Anything with browser APIs —
window,localStorage,navigator. The server doesn't have a browser.Anything that needs real-time updates — WebSockets, polling. Server Components render once, at request time.
// 🚫 Common mistake — trying to use state in a Server Component:
// ProductCard.tsx (accidentally Server Component — no 'use client')
export default async function ProductCard({ productId }) {
const product = await getProduct(productId);
// THIS WILL ERROR:
const [isWishlisted, setIsWishlisted] = useState(false);
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => setIsWishlisted(!isWishlisted)}>
{isWishlisted ? '❤️' : '🤍'}
</button>
</div>
);
}
The fix: Split it. The data-fetching part is a Server Component. The interactive part is a Client Component.
// ✅ ProductCard.tsx — Server Component
import WishlistButton from './WishlistButton'; // Client Component
export default async function ProductCard({ productId }) {
const product = await getProduct(productId);
return (
<div>
<h3>{product.name}</h3>
<p>{product.description}</p>
{/* Interactivity lives in the Client Component */}
<WishlistButton productId={productId} />
</div>
);
}
// WishlistButton.tsx — Client Component
'use client';
export default function WishlistButton({ productId }) {
const [isWishlisted, setIsWishlisted] = useState(false);
return (
<button onClick={() => setIsWishlisted(!isWishlisted)}>
{isWishlisted ? '❤️' : '🤍'}
</button>
);
}
3. The New Hooks — Actions and Async State
React 19 ships three new hooks that change how you handle form submissions and async operations. These are smaller in footprint than the Compiler and RSCs, but they'll show up in your daily code immediately.
useActionState — The End of Verbose Form State
Before React 19, a form with proper loading, error, and success states looked like this:
// Before React 19 — the verbose version
function ContactForm() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsPending(true);
setError(null);
setSuccess(false);
try {
await submitContactForm(new FormData(e.target as HTMLFormElement));
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<button disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
{error && <p className="error">{error}</p>}
{success && <p className="success">Message sent!</p>}
</form>
);
}
Six state variables and a try/catch/finally. Every form. Every time.
With useActionState:
// React 19 — useActionState
import { useActionState } from 'react';
type FormState = {
error: string | null;
success: boolean;
};
async function submitAction(
prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
await submitContactForm(formData);
return { error: null, success: true };
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Something went wrong',
success: false,
};
}
}
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitAction, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="email" type="email" />
<button disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Message sent!</p>}
</form>
);
}
Notice action={formAction} on the form. In React 19, <form> elements can accept async functions as their action prop. React handles the pending state automatically. Your component logic is now: define the action, render based on state. That's it.
useFormStatus — Knowing What's Happening Above You
This hook solves a specific but very common problem: a submit button that needs to know if the form it's inside is pending, without passing props through multiple layers.
// useFormStatus — the button knows its own form's state
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
// 'pending' is true when the parent form's action is running
// No props needed. No context. It just knows.
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// Use it anywhere inside a form:
function MyForm() {
return (
<form action={someServerAction}>
<input name="data" />
<SubmitButton /> {/* Automatically aware of form state */}
</form>
);
}
This sounds small. It isn't. In a design system with a shared Button component used across dozens of forms, this eliminates an entire class of prop-threading.
useOptimistic — The UX Win You Can Ship This Week
Optimistic updates have always been possible in React. They've never been this clean.
// useOptimistic — update UI immediately, sync with server after
import { useOptimistic, useActionState } from 'react';
type Message = {
id: string;
text: string;
sending?: boolean; // Optimistic flag
};
function MessageThread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentMessages, newMessage: Message) => [
...currentMessages,
{ ...newMessage, sending: true }, // Show immediately with 'sending' flag
]
);
const [, formAction] = useActionState(
async (prevState: null, formData: FormData) => {
const text = formData.get('text') as string;
const tempMessage: Message = { id: crypto.randomUUID(), text };
addOptimisticMessage(tempMessage); // Update UI immediately
await sendMessage(text); // Then actually send
return null;
},
null
);
return (
<div>
<ul>
{optimisticMessages.map((msg) => (
<li
key={msg.id}
style={{ opacity: msg.sending ? 0.5 : 1 }} // Visual feedback
>
{msg.text}
{msg.sending && ' (sending...)'}
</li>
))}
</ul>
<form action={formAction}>
<input name="text" />
<button type="submit">Send</button>
</form>
</div>
);
}
The message appears in the UI instantly. If the server call fails, React automatically rolls back to the previous state. If it succeeds, the optimistic message gets replaced by the real one. This is the UX pattern that makes apps feel fast — and React 19 makes it a handful of lines instead of a whole state machine.
THE TECHNICAL DEEP DIVE — React 18 vs React 19: What Changed in Production
Let me be specific. Here's a side-by-side of the most common patterns and how they change:
Data Fetching
// React 18 — data fetching in useEffect
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
try {
const data = await getUser(userId);
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Unknown error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUser();
return () => { cancelled = true; }; // Cleanup for StrictMode
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorState message={error.message} />;
if (!user) return null;
return <div>{user.name}</div>;
}
// React 19 with RSC — data fetching is just... async/await
// UserProfile.tsx (Server Component)
export default async function UserProfile({ userId }: { userId: string }) {
// This runs on the server. No useEffect. No loading state.
// Errors bubble to the nearest error.tsx boundary.
const user = await getUser(userId);
return <div>{user.name}</div>;
}
The Server Component version is not simpler by accident. It's simpler because you've moved the async work to where async work belongs: the server, where there's no browser paint to block and no cleanup function needed because there's no component lifecycle.
Error Handling
React 19 improves Error Boundaries with better integration:
// app/users/[id]/error.tsx — Next.js App Router error boundary
'use client'; // Error boundaries must be client components
export default function UserError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong loading this profile.</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
When the Server Component throws, Next.js catches it and renders this boundary automatically. No manual try/catch in your component. No complex error state management. The error surface is clean and predictable.
use() — The New Data Primitive
React 19 ships a new primitive called use() that lets you read promises and context in Client Components without useEffect:
// The new use() hook
import { use, Suspense } from 'react';
// In a Client Component:
'use client';
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
// use() suspends the component until the promise resolves
// No useEffect. No useState. No loading flag.
const user = use(userPromise);
return <div>{user.name}</div>;
}
// In the parent Server Component:
export default function Page({ params }) {
// Start fetching immediately, pass the Promise down
const userPromise = getUser(params.id);
return (
<Suspense fallback={<Spinner />}>
<UserCard userPromise={userPromise} />
</Suspense>
);
}
This pattern — start the fetch in the Server Component, pass the promise to the Client Component — eliminates the waterfall without requiring the Client Component to be async. The Suspense boundary handles the loading state.
THE UNCOMFORTABLE TRUTH
Here it is. The thing the hype doesn't say.
React 19's improvements are most valuable for developers who already understand React 18 deeply.
The Compiler helps you avoid bugs with memoization — but only if you already know what memoization is for. If you don't understand why useCallback exists, you can't tell when the Compiler has opted your component out. You'll think it's working. It isn't.
RSCs simplify data fetching — but only if you understand the server-client boundary well enough to know what can't go on the server. The number one RSC bug in production is trying to use browser APIs (localStorage, window) in a Server Component. The error message is confusing. The fix is trivial — but only if you understand the mental model.
The trap for mid-level developers: seeing the simplified code, learning the patterns by imitation, and building apps that work in development but fail in production edge cases. I've seen this happen. At Selfapy, building the first mobile app with React Native, I watched a developer copy patterns from tutorials without understanding the execution environment — and we found the bugs three days before launch.
There's no shortcut here. React 19 rewards understanding. It punishes cargo-culting.
The trap for senior developers: dismissing these features because "the fundamentals haven't changed." They have. The mental model for data fetching has changed. The boundary between server and client is a new concept that doesn't have a clean equivalent in React 18. Seniors who don't engage with RSCs seriously are going to be debugging Next.js App Router issues they don't understand.
Both traps have the same solution: go slow, go deep, understand before you build.
PRO TIP — The Migration Playbook I'd Follow Today
I wish someone had given me this specific, ordered list when App Router shipped.
Week 1 — Audit, don't migrate:
Run the React Compiler's ESLint plugin on your codebase:
npm install -D eslint-plugin-react-compiler
// .eslintrc
{
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}
Count your violations. If you have more than 20, fix them before enabling the Compiler. Each violation is a component the Compiler won't optimize.
Week 2 — Enable the Compiler on leaf components first:
Leaf components — components that don't have complex children — are the safest to enable first. Enable it on your design system components: buttons, inputs, cards. Watch your Lighthouse scores.
Week 3 — Identify your Server Component candidates:
A component is a good Server Component candidate if:
- It fetches data directly
- It doesn't have event handlers or state
- It imports heavy libraries purely for display (date-fns, lodash, chart libs)
Mark them. Plan the split.
Week 4 — Migrate one feature end-to-end:
Pick one route in your app. Migrate it fully — Server Components for data, Client Components for interactivity. Measure the bundle size difference before and after. That number becomes your business case for the rest of the migration.
The thing I wish I'd known: Don't migrate the layout layer first. Start with leaf data components. The layout layer is where the server-client boundary gets complicated, and you want to understand the pattern before you wrestle with it in the most complex part of your app.
VAR SECTION — The Objections I'd Raise in Code Review
Objection 1: "The Compiler is in beta. It's not production-ready."
Partially fair. The Compiler is beta — but it's running in production at Meta. The Instagram web app uses it. The risk profile is: the Compiler opts out of optimizing components it's unsure about, rather than optimizing them incorrectly. Your app won't break. You just might not get the optimization. For low-risk components (pure display components with no side effects), enabling it in production today is reasonable. For complex stateful components, wait for the stable release.
Objection 2: "We're not on Next.js. RSCs don't apply to us."
Currently, RSCs require framework support. Next.js App Router and Remix (via Vite + React Router v7) are the main options. If you're on Vite with React only, you're not getting RSCs yet. But the React team is working on standalone RSC support. Keep watching the React server rendering RFC. This will come to more frameworks.
Objection 3: "We don't have performance problems. Why migrate?"
That's the wrong frame. React 19 isn't about fixing performance problems you already have — it's about not building performance problems into your next project. If you're starting a new app today and not using RSCs, you're going to write waterfall data fetching that you'll need to fix in 18 months. The correct time to learn the patterns is before the technical debt exists, not after.
That said: if you have a stable, working React 18 app with no performance issues and no new features planned, there's no urgency to migrate. React 18 is supported. Don't fix what isn't broken.
BISHOY'S BOOKMARKS — إذا عاوز تغوص أعمق
📄 Article: "React Compiler Beta Release" — React Official Blog (October 2024)
Why it made my brain itch: The section explaining why the Compiler opts out of optimizing non-compliant components — silently, without errors — is the most important paragraph in the whole post. Read it twice before you enable the Compiler on your codebase.
📄 Article: "Making Sense of React Server Components" — Josh W. Comeau
Why it made my brain itch: The best visual explanation of the server-client boundary I've found anywhere. Josh draws the line between SSR and RSCs in a way that makes the 'use client' directive finally click. The diagrams alone are worth 30 minutes of your time.
📄 RFC: "React Server Components RFC #0188" — reactjs/rfcs on GitHub
Why it made my brain itch: Reading the "Motivation" section of the original RFC answers every "but why did they build this?" question. The React team wasn't chasing a trend — they were solving specific, measured pain points. Understanding those pain points makes every RSC decision obvious.
🎥 Video: "The Modern Full-Stack React Tutorial" — Theo Browne (t3dotgg) on YouTube
Why it's here: Theo covers RSCs, Server Actions, and the App Router mental model in the most honest, opinionated way I've seen. He doesn't hand-wave the hard parts. Search his channel for "React 19" — his reaction videos to the React team's announcements are unfiltered and technically solid.
📄 Docs: "React Compiler — Rules of React" — react.dev
Why it made my brain itch: This is the contract the Compiler depends on. Every violation is a component the Compiler will silently skip. Run the ESLint plugin against your codebase and count the violations before you do anything else.
💬 Quote I'm keeping:
"React Compiler is able to compile code safely by modeling both the rules of JavaScript and the rules of React." — React Labs blog, February 2024
Why this stays with me: The operative word is "safely." The Compiler won't break your app. It will just quietly not optimize the parts that don't follow the rules. That's both reassuring and dangerous — because you can think it's working when it isn't.
THE UNFINISHED CHAPTER
I want to be honest about what I don't fully understand yet.
The Compiler's interaction with complex custom hooks — I know it works well with simple hooks. I'm not confident yet about highly stateful hooks with complex dependency graphs. I've seen it behave unexpectedly with hooks that have nested useEffect calls. I'm still testing this.
The cache() primitive — React 19 ships a cache() function for deduplicating requests in Server Components. I understand the theory. I haven't built enough with it yet to have opinions about where it fails. If you have, I want to hear about it.
The long-term of RSC and client state management — Redux, Zustand, Jotai. These all work with Client Components. But as more logic moves to the server, I have a genuine open question: does the role of client-side state management shrink dramatically? In five years, is most of what we put in Zustand today just... server state managed by RSCs? I think yes. I'm not sure.
And here's the thing I got wrong for two years: I thought SSR and RSCs were the same thing. They're not even close. SSR is a rendering technique. RSCs are an architectural model. Conflating them caused me to dismiss RSC discussions as "we already do SSR." If I had understood the distinction earlier, I would have been more useful to my team when we migrated.
What are you still confused about? Drop it in the comments. I'll write the follow-up.
✨ Let's keep the conversation going!
If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.
✍️ Read more on my blog: bishoy-bishai.github.io
☕ Let's chat on LinkedIn: linkedin.com/in/bishoybishai
📘 Curious about AI? Check out my book: Surrounded by AI
Top comments (0)