Topics
v5.0.0
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 / ConceptDetail
Server ComponentRuns on server; no hooks/events; zero client JS
Client Component"use client"; full hooks, events; hydrates
IslandSelf-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 DOM

RSC 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 server

LCP 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 and useActionState, suspends on promises with use(), and shows instant feedback with useOptimistic. 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 island

Best 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 prop

Practical 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

Last updated on