TL;DR
- React Compiler auto-memoizes — no more manual
useMemo/useCallbackin most cases. - Actions (
useActionState,useTransitionwith async) replace boilerplate form handling. - useOptimistic — instant UI updates while server processes.
- use() hook reads Promises & Context (can be called conditionally!).
- Activity component — keep hidden subtrees alive without re-rendering.
- ref as prop — no more
forwardRef.
Step 1: React Compiler (Auto-Memoization)
The React team spent 3+ years building the Compiler because manual memoization was the #1 source of developer frustration. Teams were wrapping everything in useMemo, useCallback, and React.memo defensively, creating code that was harder to read than the performance problem it solved. The Compiler analyzes your code at build time, understands React's rules, and inserts memoization only where it actually helps — automatically. It's the reason React 19 lets you "just write code" without thinking about re-render optimization.
The Compiler is a Babel plugin that automatically inserts memoization at build time:
// Before (React 18) — manual memoization everywhere
const ExpensiveComponent = memo(({ data, onClick }) => {
const processed = useMemo(() => processData(data), [data]);
const handleClick = useCallback(() => onClick(processed), [processed, onClick]);
return <div onClick={handleClick}>{processed.title}</div>;
});
// After (React 19 + Compiler) — just write normal code
function ExpensiveComponent({ data, onClick }) {
const processed = processData(data);
const handleClick = () => onClick(processed);
return <div onClick={handleClick}>{processed.title}</div>;
}
// Compiler automatically determines what to memoize ✅
Enabling the Compiler
npm install babel-plugin-react-compiler
// babel.config.js or next.config.ts
module.exports = {
experimental: {
reactCompiler: true, // Next.js 15+
},
};
What the Compiler WON'T optimize
- Components that violate Rules of React (mutating props, conditional hooks)
- It tells you exactly which files it skipped and why
Step 2: useActionState — Form Handling
Every React form before React 19 required the same boilerplate: useState for loading, another for error, another for data, a submit handler that sets loading, calls the API, catches errors, and resets loading. useActionState was created to eliminate this repetitive pattern by combining state + action + loading into a single hook. It also integrates with progressive enhancement — forms work even before JavaScript loads, which matters for SSR-heavy apps.
Replaces the useState + loading + error boilerplate:
import { useActionState } from 'react';
// Action function: (previousState, formData) → newState
async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title') as string;
if (!title) return { error: 'Title is required' };
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) return { error: 'Failed to create post' };
return { success: true, message: 'Post created!' };
}
function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
// ^state ^pass to form ^loading boolean
return (
<form action={formAction}>
<input name="title" placeholder="Post title" />
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">{state.message}</p>}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Step 3: useOptimistic — Instant UI Updates
Users hate waiting. Before useOptimistic, implementing optimistic UI meant manually managing temporary state, rolling back on errors, and reconciling with server responses — each requiring 20+ lines of careful state logic. useOptimistic was built because every modern app (chat, likes, todo lists) needs instant feedback while the server processes in the background. It handles the temporary state and automatic reconciliation in one hook, making what was previously error-prone into a 3-line pattern.
Show the result immediately, reconcile when server responds:
import { useOptimistic } from 'react';
type Message = { id: string; text: string; pending?: boolean };
function Chat({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, { ...newMessage, pending: true }]
);
async function sendMessage(formData: FormData) {
const text = formData.get('message') as string;
const tempId = crypto.randomUUID();
// Show immediately in UI (with pending flag)
addOptimistic({ id: tempId, text });
// Actually send to server
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ text }),
});
// When server responds, React reconciles with real data
}
return (
<div>
{optimisticMessages.map(msg => (
<div key={msg.id} style={{ opacity: msg.pending ? 0.5 : 1 }}>
{msg.text} {msg.pending && '(sending...)'}
</div>
))}
<form action={sendMessage}>
<input name="message" />
<button type="submit">Send</button>
</form>
</div>
);
}
Step 4: use() Hook — Read Promises & Context
The use() hook breaks React's most rigid rule: it can be called conditionally and inside loops. It was invented because useContext and async data reading had a fundamental limitation — you couldn't conditionally read context or await a promise inside a component without workarounds. use() reads Promises (triggering Suspense) and Context values anywhere in your component, enabling patterns like "only load admin context if user is admin" that were previously impossible without restructuring your component tree.
Unlike other hooks, use() can be called conditionally and inside loops:
import { use, Suspense } from 'react';
// Reading a Promise
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspends until resolved
return <h1>{user.name}</h1>;
}
// Conditional usage (impossible with useContext!)
function Dashboard({ showAdmin }: { showAdmin: boolean }) {
if (showAdmin) {
const admin = use(AdminContext); // ✅ Conditional — only use() can do this
return <AdminPanel data={admin} />;
}
return <UserDashboard />;
}
// Usage with Suspense boundary
function App() {
const userPromise = fetchUser(userId); // Start fetching
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
Step 5: useEffectEvent — Non-Reactive Values in Effects
useEffectEvent solves a pain point that every React developer has hit: you need the latest value of a prop inside an effect, but adding it to the dependency array causes the effect to re-run when it shouldn't. Before this hook, the workaround was a useRef that you manually updated — ugly, error-prone, and non-obvious. useEffectEvent creates an event handler that always reads the latest values but is never a reactive dependency, keeping your effects clean and correct.
Solves the "I need the latest value but don't want to re-run the effect" problem:
import { useEffect, useEffectEvent } from 'react';
function Chat({ roomId, theme }: { roomId: string; theme: string }) {
// onConnected always reads latest `theme` without being a dependency
const onConnected = useEffectEvent(() => {
showNotification(`Connected to ${roomId}`, theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only re-runs when roomId changes, NOT theme
}
Before useEffectEvent (workaround)
// Old pattern — messy useRef workaround
const themeRef = useRef(theme);
themeRef.current = theme;
useEffect(() => {
connection.on('connected', () => {
showNotification('Connected', themeRef.current);
});
}, [roomId]);
Step 6: Activity Component — Hidden State Preservation
Before Activity, hiding a tab meant either unmounting it (losing all state, scroll position, and fetched data) or using display: none (which still triggers React re-renders and wastes CPU). The Activity component was built for tab-based UIs, step wizards, and keep-alive patterns where you want hidden content to preserve its state but not consume rendering resources. It tells React's scheduler to deprioritize updates to hidden subtrees, giving visible content priority.
Keep tabs alive without unmounting (replaces display: none hack):
import { Activity } from 'react';
function TabLayout({ activeTab }: { activeTab: string }) {
return (
<div>
<Activity mode={activeTab === 'feed' ? 'visible' : 'hidden'}>
<FeedTab /> {/* State, scroll, data all preserved when hidden */}
</Activity>
<Activity mode={activeTab === 'profile' ? 'visible' : 'hidden'}>
<ProfileTab />
</Activity>
<Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
<SettingsTab />
</Activity>
</div>
);
}
Why not just display: none?
- CSS hiding still triggers React re-renders on state updates
- Activity tells React to defer updates to hidden subtrees — better performance
Step 7: ref as a Prop (No More forwardRef)
forwardRef was one of React's most confusing APIs — wrapping a component in forwardRef just to pass a ref through was verbose, killed type inference, and confused beginners. The React team realized that ref is conceptually just a prop, so in React 19, function components can accept ref as a regular prop. This simplifies component APIs, removes a layer of indirection, and makes TypeScript inference work naturally. forwardRef still works but triggers deprecation warnings.
// React 19 — ref is just a regular prop
function Input({ ref, ...props }: { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// Usage — just pass ref directly
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <Input ref={inputRef} placeholder="Enter name" />;
}
// forwardRef still works but shows deprecation warning
React 18 vs 19 Quick Comparison
| Feature | React 18 | React 19 |
|---|---|---|
| Memoization | Manual (useMemo, useCallback, memo) |
Compiler handles automatically |
| Form mutations | useState + loading + error state |
useActionState (3 lines) |
| Optimistic UI | Third-party (SWR, TanStack) | useOptimistic built-in |
| Ref forwarding | forwardRef() required |
ref is a regular prop |
| Reading Promises | Not possible in render | use() hook |
| Hidden tabs | CSS display: none (still re-renders) |
<Activity mode="hidden"> |
| Non-reactive effect values | useRef workaround |
useEffectEvent |
Interview Questions
-
What's the React Compiler and how does it work?
- A Babel plugin that statically analyzes your code and inserts memoization at build time. It replaces manual
useMemo,useCallback, andReact.memofor components that follow the Rules of React.
- A Babel plugin that statically analyzes your code and inserts memoization at build time. It replaces manual
-
What's the difference between
useActionStateanduseTransition?useTransitionwraps any async state update as non-blocking.useActionStateis specifically for form actions — it manages the action function, its return state, andisPendingin one hook.
-
When would you use
useOptimistic?- For immediate UI feedback during mutations (add to list, like button, send message). The UI updates instantly, then reconciles when the server responds. If the request fails, React reverts to the real state.
-
How is
use()different from other hooks?- It can be called conditionally and inside loops — violating the normal rules of hooks. It reads Promises (suspending until resolved) or Context values. It enables conditional data dependencies.