On this page · sections
Modern React Architecture
Server Components run only on the server and reduce client JavaScript. Islands architecture ships static HTML by default and hydrates only interactive regions. React 19 adds use(), Actions, useActionState, and useOptimistic — building on React 18 concurrent rendering.
| Type / Concept | Detail |
|---|---|
| Server Component | Runs on server; no hooks/events; zero client JS |
| Client Component | "use client"; full hooks, events; hydrates |
| Island | Self-contained interactive component with its own JS |
use() | Suspends on promises; reads Context conditionally |
| Server Action | 'use server'; serializable mutation callable from client |
What RSC Are
Definition
Server Components execute on the server and produce a serializable output streamed to the client. They never hydrate — the browser receives HTML plus an RSC (Flight) payload.
// app/users/page.tsx — Server Component (default in App Router)
async function getUsers() {
const res = await fetch('https://api.example.com/users', {
next: { revalidate: 60 },
});
return res.json() as Promise<{ id: string; name: string }[]>;
}
export default async function UsersPage() {
const users = await getUsers();
return (
<main>
<h1>Users</h1>
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</main>
);
}Server renders component tree
↓
Flight payload — serialized tree + references
↓
Browser receives HTML + RSC payload
↓
Client Components hydrate; server output is static in the DOMRSC vs SSR: SSR sends an HTML string. RSC also sends a component tree payload for soft navigation and segment refetch without full document reload.
Interview: RSC is a component execution model. SSR is a delivery timing. They compose but are not the same thing.
Server vs Client Boundary
Definition
Server shell
→ async data fetch, secrets, large server-only libraries
Client islands
→ useState, useEffect, onClick, context providers, DOM APIs
Rule
→ push "use client" down to interaction leaves// ProductPage.tsx — server
import { AddToCartButton } from './AddToCartButton';
export async function ProductPage({ id }: { id: string }) {
const product = await getProduct(id);
return (
<article>
<h1>{product.name}</h1>
<p>${product.price}</p>
<AddToCartButton productId={product.id} />
</article>
);
}// AddToCartButton.tsx — client
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
return (
<button type="button" onClick={() => setAdded(true)}>
{added ? 'Added' : `Add ${productId}`}
</button>
);
}Import rules: Server can import Client Components. Client cannot import Server Components. Server can pass pre-rendered JSX as children into Client wrappers.
Interview:
"use client"at the app root maximizes the client bundle. Default server, client at leaves.
Islands architecture
Definition
Traditional SSR + full hydration downloads the whole app bundle and hydrates the entire tree. Islands hydrate only interactive widgets.
Full hydration → Server HTML → whole bundle → hydrate entire tree
Islands → Server HTML → JS per island → hydrate only interactive regions// app/product/[id]/page.tsx — Server Component, no component JS
export default async function ProductPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
const product = await getProduct(id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCart sku={product.sku} />
<ReviewList reviews={product.reviews} />
</article>
);
}Metrics that improve: LCP (content paints without bundle), TBT (no single long hydration task), INP (main thread free on first click).
Interview: Islands ≠ micro-frontends. Micro-frontends split by team/deployment; islands split by interactivity inside one document.
Cross-island state
Definition
URL / searchParams → shareable, server-readable, survives reload
Cookies → per-request state server render can read
Store outside React → module-level store two islands import
Server round trip → mutation + re-render of server tree'use client' in root layout → entire app becomes client code. Push directive to the lowest component that needs hooks or browser APIs.
Serializable props
Definition
Props crossing the Server → Client boundary must be JSON-like:
Serializable
→ strings, numbers, booleans, plain objects, arrays
→ server-rendered JSX as children slot
Not serializable
→ functions (except Server Actions)
→ class instances, symbols, refs// ModalShell.tsx — client
'use client';
export function ModalShell({ title, children }: { title: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Open {title}</button>
{open && <dialog open>{children}</dialog>}
</>
);
}LegalCopy renders on the server; serialized output slots into the client modal without the client importing the server module. Server Actions ('use server') are the exception — async functions become serializable callable references.
The use() hook
Definition
use(promise) suspends the nearest Suspense boundary until resolved. use(Context) can run conditionally — unlike useContext.
'use client';
import { Suspense, use } from 'react';
type User = { id: string; name: string };
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <p>{user.name}</p>;
}
function Page({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<p>Loading user…</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}Server Components use async/await directly. use() is the client bridge for stable promises from RSC or cache.
Interview: Creating a new promise every render suspends forever — stabilize upstream with React
cache()or pass from parent.
Actions and form mutations
Definition
An Action is an async function React treats as a transition. Pending state coordinates across the form subtree.
'use client';
import { useActionState, useFormStatus } from 'react-dom';
type State = { error?: string; ok?: boolean };
async function createComment(_prev: State, formData: FormData): Promise<State> {
const body = String(formData.get('body') ?? '').trim();
if (body.length < 2) return { error: 'Too short' };
await fetch('/api/comments', { method: 'POST', body: JSON.stringify({ body }) });
return { ok: true };
}
function SubmitLabel() {
const { pending } = useFormStatus();
return <button type="submit">{pending ? 'Posting…' : 'Post'}</button>;
}
function CommentForm() {
const [state, formAction] = useActionState(createComment, {});
return (
<form action={formAction}>
<textarea name="body" defaultValue="" aria-invalid={Boolean(state.error)} />
{state.error && <p role="alert">{state.error}</p>}
<SubmitLabel />
</form>
);
}useFormStatus must run in a child of <form> — not in the same component as the form root.
useOptimistic
Definition
Temporary overlay during transitions — revert on error.
'use client';
import { useOptimistic, useTransition, useState } from 'react';
function Chat({ initial }: { initial: Message[] }) {
const [messages, setMessages] = useState(initial);
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMsg: Message) => [...state, { ...newMsg, pending: true }],
);
const [pending, startTransition] = useTransition();
function handleSend(text: string) {
const temp: Message = { id: `temp-${Date.now()}`, text };
startTransition(async () => {
addOptimistic(temp);
const saved = await sendMessage(text);
setMessages((prev) => [...prev.filter((m) => m.id !== temp.id), saved]);
});
}
return (
<div>
<ul>
{optimistic.map((m) => (
<li key={m.id} style={{ opacity: m.pending ? 0.6 : 1 }}>{m.text}</li>
))}
</ul>
<button type="button" disabled={pending} onClick={() => handleSend('Hi')}>Send</button>
</div>
);
}On failure: remove optimistic row and show error. Disable submit while pending to avoid double-submit races.
Island granularity
Definition
Draw boundaries around units of shared state:
Shares state with siblings → same island
Independent lifecycle → separate island
Below fold and heavy → dynamic import with ssr: false
Needs SEO-visible content → keep on serverLCP element (hero + title) must be server markup — never inside a client island. Suspense fixes slow data; islands fix unnecessary JavaScript.
Interview: Pre-hydration window needs real form controls in server HTML —
<button>, not<div>upgraded later.
Senior Interview Summary
What to say
Server-only render with serializable output — no hydration for server code. Islands ship static HTML and hydrate only interactive regions. Push
"use client"to the lowest interactive leaf. React 19 coordinates async mutations with Actions anduseActionState, suspends on promises withuse(), and shows instant feedback withuseOptimistic. Keep React Query for read-heavy cache.
Architecture checklist
RSC vs SSR → execution model vs delivery timing
'use client' location → lowest leaf needing hooks/events
Serializable props → plain data across boundary; children slot for server JSX
Cross-island state → URL first, then cookies/store/Server Action
Suspense vs island → slow data vs unnecessary JS
LCP element → server markup, never client islandBest mental model
Server Component → "Run near data; ship zero client JS."
Island → "opt-in interactivity with its own bundle"
use() → read promise/Context during render; Suspense owns loading
Actions → async fn as transition; form action propPractical rule
Static / server-fetchable data → Server Component + cache()
Hooks / events / browser APIs → Client island at leaf
Content/commerce pages → islands win (entry-point experience)
Long-lived internal app → SPA may amortize one bundle better
After mutation → revalidateTag, not client refetch storm