Skip to content
masterclassBuddyTech Buzz5 fresh blogs · 5:00 AM IST

Technology handbook

React 19+ — Components, Actions & Server UI

Components, state, Actions, Server Components and the React Compiler — the modern React path in the order you meet it.

Topics
30
Sections
7

Foundations

Components & JSX

Build pure components from props and JSX, then compose them into a UI tree.

  • JSX compiles to function calls, so a component is an ordinary function that happens to return an element tree.
  • Curly braces embed a JavaScript expression, not a statement — which is why a loop becomes map and an if becomes a ternary.
  • A component name must start with a capital letter, or JSX emits the literal HTML tag instead of calling your function.

Example: components/card.tsx

import type { ReactNode } from "react";

type CardProps = { title: string; footer?: ReactNode; children: ReactNode };

export function Card({ title, footer, children }: CardProps) {
  return (
    <section className="card">
      <h2>{title}</h2>
      <div>{children}</div>
      {footer && <footer className="card-footer">{footer}</footer>}
    </section>
  );
}

One card component, fifty places: A product tile appears on search, wishlist and checkout; written as a component, a design change lands once instead of in three diverging copies.

Props and composition

Design explicit component APIs and compose small pieces instead of duplicating UI logic.

  • Props are read-only inputs: writing to one mutates the parent's data and produces a render that React cannot reproduce.
  • children is the prop that lets a wrapper hold content it knows nothing about, which is how layout components avoid a prop per slot.
  • Passing a component as a prop beats adding another boolean flag — three booleans on one component is the signal it should have been composed.

Example: components/dialog.tsx

"use client";
import { useEffect, useRef, type ReactNode } from "react";

export function Dialog({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) {
  const ref = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    if (open) ref.current?.showModal(); else ref.current?.close();
  }, [open]);
  return (
    <dialog ref={ref} onClose={onClose} aria-labelledby="dialog-title">
      <h2 id="dialog-title">{title}</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </dialog>
  );
}

A dialog that wraps anything: A modal has to hold a form today and a chart tomorrow; taking children keeps it from growing a type prop with a switch inside.

Refs and the DOM

Reach an imperative DOM or browser API only when declarative rendering is not enough.

  • Changing ref.current does not re-render, which is exactly why it suits values the UI does not display.
  • The node is null until the element mounts, so DOM work belongs in an Effect or an event handler, never in the render body.
  • Reading layout from a ref forces the browser to compute style, so measuring in a loop is a common cause of jank.

Example: components/checkout-form.tsx

"use client";
import { useRef, type FormEvent } from "react";

export function CheckoutForm() {
  const form = useRef<HTMLFormElement>(null);

  function onSubmit(e: FormEvent) {
    if (!form.current?.checkValidity()) {
      e.preventDefault();
      form.current?.querySelector<HTMLInputElement>(":invalid")?.focus();
    }
  }

  return (
    <form ref={form} onSubmit={onSubmit} noValidate>
      <input name="name" required placeholder="Full name" />
      <input name="pin" required pattern="\d{6}" placeholder="PIN code" />
      <button>Pay</button>
    </form>
  );
}

Focusing the first field of a form: Opening a dialog should put the cursor in its first input; the browser owns focus, so React needs a handle to the node to ask for it.

Conditional rendering

Render alternate UI states clearly with conditions, guards and early returns.

  • && renders 0 when the left side is the number zero — the reason an empty list sometimes shows a stray 0.
  • An early return keeps the loading and error paths out of the main body, which is what keeps a component readable.
  • Returning null renders nothing and is different from returning undefined, which React treats as a mistake.

Example: components/orders-view.tsx

type Props =
  | { status: "loading" }
  | { status: "error"; message: string; retry: () => void }
  | { status: "ready"; orders: { id: string; total: number }[] };

export function OrdersView(props: Props) {
  if (props.status === "loading") return <p aria-busy="true">Loading orders…</p>;
  if (props.status === "error") return <p role="alert">{props.message} <button onClick={props.retry}>Retry</button></p>;
  if (props.orders.length === 0) return <p>No orders yet — <a href="/shop">start shopping</a>.</p>;
  return <ul>{props.orders.map(o => <li key={o.id}>₹{o.total}</li>)}</ul>;
}

Empty, loading, error and ready: Every data-backed screen has four states, and handling them explicitly is what stops an empty result rendering as a broken layout.

Lists and keys

Render collections with stable keys that represent item identity rather than array position.

  • A key identifies an item across renders; the array index is not one when the list can reorder, filter or grow at the front.
  • A wrong key is why an input's text follows the wrong row after a sort — React reused the element it thought was the same.
  • Keys must be unique among siblings only, not globally, and they never reach the component as a prop.

Example: components/editable-table.tsx

"use client";
import { useState } from "react";

type Row = { id: string; name: string };

export function EditableTable({ initial }: { initial: Row[] }) {
  const [rows, setRows] = useState(initial);
  const sort = () => setRows(r => r.toSorted((a, b) => a.name.localeCompare(b.name)));
  return (
    <>
      <button onClick={sort}>Sort A–Z</button>
      <ul>
        {/* key={index} would move typed notes onto the wrong row after sorting */}
        {rows.map(row => <li key={row.id}>{row.name} <input placeholder="note" /></li>)}
      </ul>
    </>
  );
}

A sortable table with inputs in it: Sorting a list of editable rows moves the values to the wrong rows when keyed by index — one of the most-reported React bugs.

useId

Generate stable accessible IDs that match between server rendering and hydration.

  • It produces an identifier that is identical in the server render and the client render, which a counter cannot.
  • It is for linking elements — label to input, input to its description — not for list keys.
  • One call can prefix several ids in a component, so a form field does not need three separate calls.

Example: components/password-field.tsx

"use client";
import { useId } from "react";

export function PasswordField() {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>Password</label>
      <input id={id} type="password" aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>At least 12 characters.</p>
    </div>
  );
}

A reusable field component: The same input rendered three times on a page needs three distinct label associations, and hard-coded ids collide.

State & effects

State with useState

Keep interaction state local and update it with event-driven state setters.

  • The setter queues an update — reading the state variable on the next line still gives the previous value.
  • Pass a function to the setter when the next value depends on the last one, or two updates in the same tick collapse into one.
  • Passing a function to useState itself makes the initial value lazy, so an expensive default is computed once and not on every render.

Example: components/comment-box.tsx

"use client";
import { useState } from "react";

export function CommentBox() {
  const [tab, setTab] = useState<"write" | "preview">("write");
  const [notify, setNotify] = useState(true);
  const [draft, setDraft] = useState("");

  return (
    <div>
      <button onClick={() => setTab("write")} aria-pressed={tab === "write"}>Write</button>
      <button onClick={() => setTab("preview")} aria-pressed={tab === "preview"}>Preview</button>
      {tab === "write"
        ? <textarea value={draft} onChange={e => setDraft(e.target.value)} />
        : <p>{draft || "Nothing to preview"}</p>}
      <label><input type="checkbox" checked={notify} onChange={() => setNotify(n => !n)} /> Email me replies</label>
    </div>
  );
}

A tab, a toggle, a draft: State that belongs to one screen and nothing else — putting it in a global store instead is what makes two screens fight over one value.

Reducers & context

Model complex transitions with reducers and share stable values through context.

  • A reducer moves the transition rules out of the handlers, so 'what can happen next' is readable in one function.
  • A reducer must be pure: the same state and action produce the same next state, which is what makes it testable without a component.
  • Context re-renders every consumer when its value changes, so a new object literal as the value re-renders them all on every parent render.

Example: components/wizard/wizard-context.tsx

"use client";
import { createContext, useContext, useReducer, type ReactNode, type Dispatch } from "react";

type State = { step: 1 | 2 | 3; data: { name?: string; plan?: string } };
type Action = { type: "next"; data: Partial<State["data"]> } | { type: "back" };

function reducer(state: State, action: Action): State {
  if (action.type === "back") return { ...state, step: Math.max(1, state.step - 1) as State["step"] };
  return { step: Math.min(3, state.step + 1) as State["step"], data: { ...state.data, ...action.data } };
}

const WizardContext = createContext<{ state: State; dispatch: Dispatch<Action> } | null>(null);

export function WizardProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { step: 1, data: {} });
  return <WizardContext value={{ state, dispatch }}>{children}</WizardContext>;
}

export function useWizard() {
  const ctx = useContext(WizardContext);
  if (!ctx) throw new Error("useWizard must be inside <WizardProvider>");
  return ctx;
}

A multi-step wizard: Steps, validation and a back button turn into a tangle of useState calls that can reach impossible combinations; a reducer names the legal transitions.

Effects and synchronization

Synchronize with external systems only when rendering alone cannot express the work.

  • An Effect runs after paint, so anything that must happen before the browser draws belongs in useLayoutEffect instead.
  • The cleanup function is not optional for subscriptions and timers: without it a component that mounts twice leaks the first one.
  • Work that only derives a value from props or state does not need an Effect — computing it during render is simpler and cannot go stale.

Example: components/live-price.tsx

"use client";
import { useEffect, useState } from "react";

export function LivePrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);

  useEffect(() => {
    const source = new EventSource(`/api/prices/${symbol}`);
    source.onmessage = e => setPrice(Number(e.data));
    return () => source.close();
  }, [symbol]);

  return <p>{symbol}: {price ?? "…"}</p>;
}

A live price that must stop when the panel closes: A socket opened on mount keeps pushing updates after navigation unless the teardown runs — the classic memory leak in a dashboard.

useEffectEvent

Read the latest props and state inside an Effect without making the Effect resubscribe.

  • It separates the values an Effect reacts to from the values it merely reads, which is what a stale-closure bug is really about.
  • The function it returns is not reactive, so it must not appear in the dependency array.
  • It can only be called from inside an Effect in the same component — passing it elsewhere is a lint error, not a style preference.

Example: components/chat-room.tsx

"use client";
import { useEffect, useEffectEvent } from "react";
import { createConnection } from "@/lib/chat";
import { track } from "@/lib/analytics";

export function ChatRoom({ roomId, plan }: { roomId: string; plan: string }) {
  const onConnected = useEffectEvent(() => {
    track("room_joined", { roomId, plan }); // always the current plan
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", onConnected);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // plan is not a dependency

  return <h2>Room {roomId}</h2>;
}

An analytics call inside a subscription: Logging the current filter when a message arrives should not make the socket reconnect every time the filter changes.

Actions & forms

Actions and forms

Handle async mutations with useActionState, useFormStatus and progressive enhancement.

  • An action receives the FormData, so the form works before JavaScript loads and keeps working after it does.
  • Pending, error and result come from the framework, replacing the four useState calls a hand-written submit handler needs.
  • Actions are queued: a double-clicked submit runs in order rather than racing itself.

Example: app/checkout/actions.ts

"use server";
import { db } from "@/lib/db";

export type CheckoutState = { ok?: boolean; error?: string };

export async function placeOrder(_prev: CheckoutState, formData: FormData): Promise<CheckoutState> {
  const key = String(formData.get("idempotencyKey"));
  const existing = await db.order.findUnique({ where: { idempotencyKey: key } });
  if (existing) return { ok: true };
  try {
    await db.order.create({ data: { idempotencyKey: key, cartId: String(formData.get("cartId")) } });
    return { ok: true };
  } catch {
    return { error: "Payment failed — your card was not charged. Please retry." };
  }
}

A checkout that survives a flaky connection: A submit on a train has to show pending, report failure and not charge twice — the states a hand-rolled handler usually gets wrong.

useOptimistic

Show an immediate optimistic result while an async action is still running.

  • The optimistic value exists only while the action is in flight; it is discarded when the real result arrives.
  • React reverts it automatically on failure, so no rollback code is written by hand.
  • It is a render-time projection, not state — the server's answer remains the source of truth.

Example: app/posts/actions.ts

"use server";
import { updateTag } from "next/cache";

export async function likePost(postId: string) {
  // await db.like.create({ data: { postId } });
  updateTag(`post-${postId}`);
}

A Like that responds instantly: Waiting 400ms for a round trip makes a button feel broken; showing the new count immediately and correcting on failure is what feels native.

Form status

Show pending state from inside a form with useFormStatus without passing it through every component.

  • It reads the status of the nearest parent form, so a submit button does not need the state passed down to it.
  • It must be called from a component rendered inside the form — the form's own component cannot read its own status.
  • It exposes the pending flag, the data being sent and the method, which is enough to disable and label a button correctly.

Example: components/submit-button.tsx

"use client";
import { useFormStatus } from "react-dom";

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? "Saving…" : children}
    </button>
  );
}

A shared submit button component: One button used in twelve forms shows the right spinner without each form threading a isSubmitting prop into it.

Controlled inputs

Keep form values in React state when validation and coordinated updates need a single source of truth.

  • A controlled input renders the value from state, so validation and formatting can happen between keystroke and display.
  • An input with value and no onChange is read-only, which React warns about because it is almost always an oversight.
  • Uncontrolled inputs with defaultValue are lighter, and suit a form read once on submit rather than on every keystroke.

Example: lib/format-card.ts

export function formatCardNumber(raw: string) {
  const digits = raw.replace(/\D/g, "").slice(0, 16);
  return digits.replace(/(\d{4})(?=\d)/g, "$1 ");
}

// formatCardNumber("4111111111111111") → "4111 1111 1111 1111"

A card-number field that formats as you type: Inserting spaces every four digits and rejecting letters needs the value to pass through state before it is displayed.

useActionState

Pair an action with its result and pending state to build resilient async forms.

  • It returns the last result alongside the action, so a server error message can be rendered without separate state.
  • The action receives the previous state as its first argument, which makes retry and accumulate patterns natural.
  • It works with a plain form element, so the page still submits when JavaScript has not loaded.

Example: app/signup/actions.ts

"use server";
import { redirect } from "next/navigation";
import { z } from "zod";

const schema = z.object({ email: z.email(), password: z.string().min(12) });
export type SignupState = { errors?: { email?: string; password?: string } };

export async function signup(_prev: SignupState, formData: FormData): Promise<SignupState> {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    const f = parsed.error.flatten().fieldErrors;
    return { errors: { email: f.email?.[0], password: f.password?.[0] } };
  }
  // await db.user.create({ data: parsed.data });
  redirect("/welcome");
}

A sign-in form that reports why it failed: The server's 'wrong password' has to reach the field without a store, a context or an effect syncing it into state.

Async UI

Suspense and use

Read promises and coordinate loading boundaries without hand-written loading flags.

  • use reads a promise during render and suspends until it settles, so no isLoading flag is written.
  • The nearest Suspense boundary decides what is shown while it waits, which makes loading a layout decision rather than a per-component one.
  • Unlike a Hook, use may be called inside a condition — the one exception to the usual rule.

Example: app/dashboard/page.tsx

import { Suspense } from "react";
import { Summary } from "./summary";
import { Recommendations } from "./recommendations";

export default function Dashboard() {
  return (
    <>
      <Summary /> {/* fast query */}
      <Suspense fallback={<p className="skeleton">Finding recommendations…</p>}>
        <Recommendations /> {/* 2 s ML call */}
      </Suspense>
    </>
  );
}

A page that streams its slow panel last: Header and navigation appear immediately while a slow report renders behind its own boundary, instead of the whole route waiting on one query.

Error boundaries

Contain rendering failures and provide a useful recovery path for users.

  • A boundary catches errors thrown while rendering its children, not in event handlers or async callbacks.
  • Without one, a thrown render error unmounts the whole tree — React's deliberate choice over showing corrupted UI.
  • A boundary needs a reset path, or the reader is left on an error screen with nothing to do but reload.

Example: components/error-boundary.tsx

"use client";
import { Component, type ReactNode } from "react";

export class ErrorBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
  state = { failed: false };
  static getDerivedStateFromError() { return { failed: true }; }
  componentDidCatch(error: Error) { console.error("Widget failed:", error); }
  render() { return this.state.failed ? this.props.fallback : this.props.children; }
}

One broken widget, not a blank page: A third-party chart that throws on malformed data should leave the rest of the dashboard usable and offer a retry.

Server & client

Server Components

Render data-heavy components on the server and keep client boundaries intentional.

  • They run only on the server and never ship their code to the browser, which is why a heavy formatting library used there costs the client nothing.
  • Props crossing into a Client Component must be serializable, so a function cannot simply be passed down.
  • They have no state and no Effects — interactivity begins at the 'use client' boundary.

Example: app/articles/[slug]/page.tsx

import Markdown from "react-markdown";
import { getArticle } from "@/lib/cms";
import { ShareButton } from "./share-button";

export default async function Article({ params }: PageProps<"/articles/[slug]">) {
  const article = await getArticle((await params).slug);
  return (
    <article>
      <h1>{article.title}</h1>
      <div className="prose"><Markdown>{article.markdown}</Markdown></div>
      <ShareButton url={`/articles/${article.slug}`} />
    </article>
  );
}

A content page that stays light on mobile: Rendering markdown or a large table on the server ships HTML instead of the parser plus the data, which is most of the bundle on a content-heavy route.

Server Functions

Call server-side mutations from a React UI while treating every function as a public endpoint.

  • Every server function is a public endpoint: arguments arrive from the network and must be validated and authorized inside it.
  • It returns serializable data to the caller, so a database row with methods on it cannot be returned as-is.
  • Calling one from a client component creates an implicit request, which is why an accidental call in a loop becomes a traffic problem.

Example: app/editor/actions.ts

"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function saveDraft(postId: string, body: string) {
  const session = await auth();
  const post = await db.post.findUnique({ where: { id: postId } });
  if (!session || post?.authorId !== session.userId) throw new Error("Not allowed");
  await db.post.update({ where: { id: postId }, data: { body, savedAt: new Date() } });
  return { savedAt: new Date().toISOString() };
}

Saving a draft without an API layer: The editor calls a function that writes to the database, replacing a hand-written route, a fetch wrapper and the types kept in sync between them.

Context as a provider

Provide shared configuration and state without threading props through every component.

  • In React 19 the context object itself is the provider, so <Context> replaces <Context.Provider>.
  • Context is not a state manager: it distributes a value, and every consumer re-renders when that value changes identity.
  • Splitting one context into a value and a dispatch context keeps components that only dispatch from re-rendering.

Example: app/providers.tsx

"use client";
import { createContext, useMemo, useState, type ReactNode } from "react";

export const ThemeContext = createContext<"light" | "dark">("light");
export const LocaleContext = createContext("en-IN");
export const UserContext = createContext<{ name: string } | null>(null);

export function Providers({ user, children }: { user: { name: string } | null; children: ReactNode }) {
  const [theme] = useState<"light" | "dark">("light");
  const stableUser = useMemo(() => user, [user]);
  return (
    <ThemeContext value={theme}>
      <LocaleContext value="en-IN">
        <UserContext value={stableUser}>{children}</UserContext>
      </LocaleContext>
    </ThemeContext>
  );
}

Theme, locale and the signed-in user: Values needed at every depth of the tree, where threading props would touch every component in between.

Hydration and mismatch debugging

Keep server and client output consistent and diagnose differences with React 19 error details.

  • A mismatch means server and client rendered different HTML — Date.now(), Math.random() and localStorage are the usual causes.
  • React 19 prints the differing tree rather than a generic warning, which turns a hunt into a read.
  • Rendering browser-only content after mount, or with suppressHydrationWarning, is the deliberate fix — not disabling SSR for the page.

Example: components/posted-at.tsx

// ❌ Mismatch: server runs in UTC, the reader is in IST
export function PostedAtBroken({ iso }: { iso: string }) {
  return <time>{new Date(iso).toLocaleTimeString()}</time>;
}

A timestamp that breaks hydration: 'Posted 2 minutes ago' computed during both renders produces different text and discards the server HTML for that subtree.

Performance

React Compiler

Use build-time automatic memoization while keeping the Rules of React intact.

  • It memoizes at build time by proving what a component reads, which removes most hand-written useMemo and useCallback.
  • It only optimizes code that follows the Rules of React — mutating props or state is what makes it bail out on a component.
  • Its output is ordinary React, so a component it skipped still works; it is a performance tool, not a requirement.

Example: next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = { reactCompiler: true };
export default nextConfig;

Deleting a file of memo wrappers: A mature codebase accumulates memoization nobody can justify; the compiler lets the team remove it without measuring each one by hand.

Transitions

Keep urgent input responsive while rendering non-urgent updates in the background.

  • Marking an update as a transition tells React it may be interrupted by a more urgent one, such as a keystroke.
  • The pending flag belongs to the transition, which is how a filter can show it is working without blocking the input.
  • Updates inside a transition are still applied — it changes their priority, never whether they happen.

Example: components/row-filter.tsx

"use client";
import { useState, useTransition } from "react";
import { BigTable } from "./big-table";

export function RowFilter({ rows }: { rows: { id: number; name: string }[] }) {
  const [text, setText] = useState("");
  const [filter, setFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <input value={text} onChange={e => {
        setText(e.target.value);                           // urgent
        startTransition(() => setFilter(e.target.value));  // can wait
      }} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <BigTable rows={rows.filter(r => r.name.includes(filter))} />
      </div>
    </>
  );
}

A filter over ten thousand rows: Typing stays smooth because re-rendering the table is interruptible, instead of every keystroke waiting for the list to finish.

useTransition

Mark non-urgent updates so urgent interactions remain responsive during rendering.

  • It gives you the pending flag directly, unlike startTransition on its own.
  • Updates inside its callback must be synchronous — an await before setState escapes the transition.
  • It does not make rendering faster; it changes what may be interrupted, which is what makes input feel fast.

Example: components/tabs.tsx

"use client";
import { useState, useTransition } from "react";
import { Posts } from "./posts"; // slow to render
import { About } from "./about";

export function Tabs() {
  const [tab, setTab] = useState<"about" | "posts">("about");
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <button onClick={() => startTransition(() => setTab("about"))}>About</button>
      <button onClick={() => startTransition(() => setTab("posts"))}>Posts</button>
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {tab === "about" ? <About /> : <Posts />}
      </div>
    </>
  );
}

Switching a heavy tab: The tab highlight moves immediately while the expensive panel renders behind it, instead of the click appearing to do nothing for 300ms.

useDeferredValue

Defer an expensive view while keeping the current input responsive.

  • It keeps the previous value on screen while a new one renders, which is what avoids a flash of empty results.
  • It takes a value rather than an update, so it suits props you receive and cannot wrap in a transition.
  • Comparing the deferred value with the current one is how a component knows it is showing stale output.

Example: components/product-search.tsx

"use client";
import { useDeferredValue, useState } from "react";
import { ResultList } from "./result-list";

export function ProductSearch() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);
  const stale = query !== deferredQuery;
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <div style={{ opacity: stale ? 0.5 : 1 }}><ResultList query={deferredQuery} /></div>
    </>
  );
}

Search results that lag the input on purpose: The text field updates on every keystroke while the expensive result list catches up, showing the last good results meanwhile.

useMemo and useCallback

Stabilize values or callbacks only when identity affects a real optimization or Effect dependency.

  • Both cost memory and comparison work on every render, so an unmeasured memo is usually a net loss.
  • useCallback matters when the identity of a function is a dependency — of a memoized child, or of an Effect.
  • An empty dependency array with values read inside it is the stale-closure bug these two cause most often.

Example: components/contacts.tsx

"use client";
import { memo, useCallback, useMemo, useState } from "react";

const ContactList = memo(function ContactList({ items, onSelect }: { items: string[]; onSelect: (n: string) => void }) {
  return <ul>{items.map(n => <li key={n} onClick={() => onSelect(n)}>{n}</li>)}</ul>;
});

export function Contacts({ all }: { all: string[] }) {
  const [note, setNote] = useState("");
  const [selected, setSelected] = useState("");
  const sorted = useMemo(() => all.toSorted(), [all]);
  const onSelect = useCallback((name: string) => setSelected(name), []);
  return (
    <>
      <textarea value={note} onChange={e => setNote(e.target.value)} />
      <ContactList items={sorted} onSelect={onSelect} />
      <p>Selected: {selected}</p>
    </>
  );
}

A child that re-renders for no reason: A memoized list re-renders on every parent update because its callback prop is a new function each time — the case where stabilizing identity actually pays.

Modern React

Activity and View Transitions

Preserve hidden UI work and coordinate visual transitions in React 19.2 and later.

  • Activity keeps a hidden subtree's state and Effects available so returning to it does not rebuild it from scratch.
  • View Transitions hand the browser a before and after so it can animate between them without a library.
  • Both are opt-in per area of the UI — applying them globally is how an app ends up feeling slower, not faster.

Example: components/tab-panels.tsx

"use client";
import { Activity, useState } from "react";
import { Inbox } from "./inbox";
import { Archive } from "./archive";

export function TabPanels() {
  const [tab, setTab] = useState<"inbox" | "archive">("inbox");
  return (
    <>
      <button onClick={() => setTab("inbox")}>Inbox</button>
      <button onClick={() => setTab("archive")}>Archive</button>
      <Activity mode={tab === "inbox" ? "visible" : "hidden"}><Inbox /></Activity>
      <Activity mode={tab === "archive" ? "visible" : "hidden"}><Archive /></Activity>
    </>
  );
}

Switching tabs without losing scroll: Coming back to a tab should restore where the reader was and animate the change, rather than remounting and jumping to the top.

ref as a prop

Receive refs directly in function components without a forwardRef wrapper in React 19.

  • Function components receive ref like any other prop in React 19, so forwardRef is no longer needed.
  • Existing forwardRef components keep working — this is an addition, not a breaking change.
  • A ref cleanup function may now be returned from the ref callback, which replaces the null-check on unmount.

Example: components/text-field.tsx

import type { Ref } from "react";

export function TextField({ label, ref, ...rest }: { label: string; ref?: Ref<HTMLInputElement> } & React.InputHTMLAttributes<HTMLInputElement>) {
  return (
    <label>
      {label}
      <input ref={ref} {...rest} />
    </label>
  );
}

A design-system input the parent can focus: Every wrapped input used to need a forwardRef layer; the prop now passes straight through, one wrapper fewer in the stack trace.

Document metadata

Declare title and metadata from components so React can place them in the document head.

  • A <title> or <meta> rendered in a component is hoisted into the document head automatically.
  • It works in Server and Client Components, so a route and a dialog can each set what they need.
  • Duplicates are not de-duplicated for you — the last rendered tag wins, which matters for nested layouts.

Example: components/blog-post.tsx

export function BlogPost({ post }: { post: { title: string; excerpt: string; body: string } }) {
  return (
    <article>
      <title>{`${post.title} | My Blog`}</title>
      <meta name="description" content={post.excerpt} />
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

Per-page titles without a head library: A product page sets its own title and description from the record it just rendered, rather than through a separate metadata mechanism.

Stylesheets and preloading

Coordinate stylesheet precedence and resource preloading from the component tree.

  • A stylesheet rendered by a component is inserted once with a declared precedence, so load order stops depending on import order.
  • preload and preinit start a fetch before the element that needs it renders, which removes a round trip on navigation.
  • React de-duplicates these across components, so two cards asking for the same font request it once.

Example: components/map-widget.tsx

import { preconnect, preload } from "react-dom";

export function MapWidget() {
  preconnect("https://tiles.example.com");
  preload("https://tiles.example.com/sprite.png", { as: "image" });
  return (
    <>
      <link rel="stylesheet" href="/widgets/map.css" precedence="default" />
      <div className="map" />
    </>
  );
}

A widget that brings its own CSS: A chart used on three routes should load its stylesheet exactly once, at a predictable precedence relative to the app's own.

Ask follow-up questions on any topic in your dashboard.

Start learning